mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 18:53:24 +00:00
Compare commits
14 Commits
webrtc-wor
...
d575022412
| Author | SHA1 | Date | |
|---|---|---|---|
| d575022412 | |||
| 84ceae9a05 | |||
| c5f640bc62 | |||
| 9163e5166c | |||
| 7d3b19a2b0 | |||
| 7d19557966 | |||
| 70fd6bd16a | |||
| 6dece31f2d | |||
| b741e8f40c | |||
| 2c20af83c9 | |||
| 78c16c95f5 | |||
| 953f62ce81 | |||
| c46bfb40a9 | |||
| 50eeec5164 |
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Rondevu Demo Development Guidelines
|
||||||
|
|
||||||
|
## WebRTC Configuration
|
||||||
|
|
||||||
|
### TURN Server Setup
|
||||||
|
|
||||||
|
When configuring TURN servers:
|
||||||
|
|
||||||
|
- ✅ **DO** include the port number in TURN URLs: `turn:server.com:3478`
|
||||||
|
- ✅ **DO** test TURN connectivity before deploying: `turnutils_uclient -u user -w pass server.com 3478 -y`
|
||||||
|
- ✅ **DO** provide both TCP and UDP transports for maximum compatibility
|
||||||
|
- ❌ **DON'T** omit the port number (even if it's the default 3478)
|
||||||
|
- ❌ **DON'T** assume TURN works without testing
|
||||||
|
|
||||||
|
### ICE Configuration
|
||||||
|
|
||||||
|
**Force Relay Mode for Testing:**
|
||||||
|
```javascript
|
||||||
|
const RTC_CONFIG = {
|
||||||
|
iceServers: [...],
|
||||||
|
iceTransportPolicy: 'relay' // Forces TURN relay, bypasses NAT issues
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `iceTransportPolicy: 'relay'` to:
|
||||||
|
- Test if TURN server is working correctly
|
||||||
|
- Bypass NAT hairpinning issues (when both peers are on same network)
|
||||||
|
- Ensure maximum compatibility
|
||||||
|
|
||||||
|
**Remove or comment out** `iceTransportPolicy: 'relay'` for production to allow direct connections when possible.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Enable Detailed ICE Logging
|
||||||
|
|
||||||
|
The demo includes detailed ICE candidate logging. Check browser console for:
|
||||||
|
- 🧊 ICE candidate gathering
|
||||||
|
- 🧊 ICE connection state changes
|
||||||
|
- 📤 Candidates sent to server
|
||||||
|
- 📥 Candidates received from server
|
||||||
|
- ✅ Successful candidate pairs
|
||||||
|
- ❌ Failed candidate pairs
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Connection stuck in "connecting":**
|
||||||
|
- Enable relay-only mode to test TURN
|
||||||
|
- Check if both peers are behind same NAT (hairpinning issue)
|
||||||
|
- Verify TURN credentials are correct
|
||||||
|
|
||||||
|
2. **No candidates gathered:**
|
||||||
|
- Check STUN/TURN server URLs
|
||||||
|
- Verify firewall isn't blocking UDP ports
|
||||||
|
- Check TURN server is running
|
||||||
|
|
||||||
|
3. **Candidates gathered but connection fails:**
|
||||||
|
- Check if TURN relay is actually working (use `turnutils_uclient`)
|
||||||
|
- Verify server is filtering candidates by role correctly
|
||||||
|
- Enable detailed logging to see which candidate pairs are failing
|
||||||
|
|
||||||
|
## UI Guidelines
|
||||||
|
|
||||||
|
- Show clear connection status (waiting, connecting, connected, failed)
|
||||||
|
- Display peer role (offerer vs answerer) for debugging
|
||||||
|
- Provide visual feedback for all user actions
|
||||||
|
- Use toast notifications for errors and success messages
|
||||||
148
README.md
148
README.md
@@ -5,8 +5,9 @@
|
|||||||
Experience topic-based peer discovery and WebRTC connections using the Rondevu signaling platform.
|
Experience topic-based peer discovery and WebRTC connections using the Rondevu signaling platform.
|
||||||
|
|
||||||
**Related repositories:**
|
**Related repositories:**
|
||||||
- [rondevu-server](https://github.com/xtr-dev/rondevu) - HTTP signaling server
|
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
|
||||||
- [rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library
|
- [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server), [live](https://api.ronde.vu))
|
||||||
|
- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://ronde.vu))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,14 +18,15 @@ This demo showcases the complete Rondevu workflow:
|
|||||||
1. **Register** - Get peer credentials (automatically saved)
|
1. **Register** - Get peer credentials (automatically saved)
|
||||||
2. **Create Offers** - Advertise your WebRTC connection on topics
|
2. **Create Offers** - Advertise your WebRTC connection on topics
|
||||||
3. **Discover Peers** - Find other peers by topic
|
3. **Discover Peers** - Find other peers by topic
|
||||||
4. **Connect** - Establish direct P2P WebRTC connections
|
4. **Connect** - Establish direct P2P WebRTC connections via `RondevuPeer`
|
||||||
5. **Chat** - Send messages over WebRTC data channels
|
5. **Chat** - Send messages over WebRTC data channels
|
||||||
|
|
||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Topic-Based Discovery** - Find peers by shared topics (like torrent infohashes)
|
- **Topic-Based Discovery** - Find peers by shared topics (like torrent infohashes)
|
||||||
- **Real P2P Connections** - Actual WebRTC data channels (not simulated)
|
- **Real P2P Connections** - Actual WebRTC data channels (not simulated)
|
||||||
- **Connection Manager** - Uses high-level `RondevuConnection` API (no manual WebRTC plumbing)
|
- **State-Based Peer Management** - Uses `RondevuPeer` with clean state machine (idle → creating-offer → waiting-for-answer → exchanging-ice → connected)
|
||||||
|
- **Trickle ICE** - Fast connection establishment by sending ICE candidates as they're discovered
|
||||||
- **Persistent Credentials** - Saves authentication to localStorage
|
- **Persistent Credentials** - Saves authentication to localStorage
|
||||||
- **Topics Browser** - Browse all active topics and peer counts
|
- **Topics Browser** - Browse all active topics and peer counts
|
||||||
- **Multiple Connections** - Support multiple simultaneous peer connections
|
- **Multiple Connections** - Support multiple simultaneous peer connections
|
||||||
@@ -109,76 +111,121 @@ The easiest way to test:
|
|||||||
|
|
||||||
## Technical Implementation
|
## Technical Implementation
|
||||||
|
|
||||||
### Connection Manager
|
### RondevuPeer State Machine
|
||||||
|
|
||||||
This demo uses the high-level `RondevuConnection` class which abstracts all WebRTC complexity:
|
This demo uses the `RondevuPeer` class which implements a clean state-based connection lifecycle:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Create connection
|
import { Rondevu } from '@xtr-dev/rondevu-client';
|
||||||
const conn = client.createConnection();
|
|
||||||
|
// Create peer
|
||||||
|
const peer = client.createPeer();
|
||||||
|
|
||||||
// Set up event listeners
|
// Set up event listeners
|
||||||
conn.on('connected', () => {
|
peer.on('state', (state) => {
|
||||||
console.log('P2P connection established!');
|
console.log('Peer state:', state);
|
||||||
|
// Offerer: idle → creating-offer → waiting-for-answer → exchanging-ice → connected
|
||||||
|
// Answerer: idle → answering → exchanging-ice → connected
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('datachannel', (channel) => {
|
peer.on('connected', () => {
|
||||||
channel.onmessage = (event) => {
|
console.log('✅ P2P connection established!');
|
||||||
console.log('Message:', event.data);
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create offer
|
peer.on('datachannel', (channel) => {
|
||||||
await conn.createOffer({
|
channel.addEventListener('message', (event) => {
|
||||||
|
console.log('📥 Message:', event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
channel.addEventListener('open', () => {
|
||||||
|
// Channel is ready, can send messages
|
||||||
|
channel.send('Hello!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('failed', (error) => {
|
||||||
|
console.error('❌ Connection failed:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create offer (offerer)
|
||||||
|
await peer.createOffer({
|
||||||
topics: ['demo-room'],
|
topics: ['demo-room'],
|
||||||
ttl: 300000
|
ttl: 300000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Or answer an offer
|
// Or answer an offer (answerer)
|
||||||
await conn.answer(offerId, offerSdp);
|
await peer.answer(offerId, offerSdp, {
|
||||||
|
topics: ['demo-room']
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
The connection manager handles:
|
### Connection States
|
||||||
- Offer/answer SDP generation
|
|
||||||
- ICE candidate gathering and exchange
|
**Offerer Flow:**
|
||||||
- Automatic polling for answers and candidates
|
1. **idle** - Initial state
|
||||||
- Data channel lifecycle
|
2. **creating-offer** - Creating WebRTC offer and sending to server
|
||||||
- Connection state management
|
3. **waiting-for-answer** - Polling for answer from peer (every 2 seconds)
|
||||||
- Event-driven API
|
4. **exchanging-ice** - Exchanging ICE candidates (polling every 1 second)
|
||||||
|
5. **connected** - Successfully connected!
|
||||||
|
6. **failed/closed** - Connection failed or was closed
|
||||||
|
|
||||||
|
**Answerer Flow:**
|
||||||
|
1. **idle** - Initial state
|
||||||
|
2. **answering** - Creating WebRTC answer and sending to server
|
||||||
|
3. **exchanging-ice** - Exchanging ICE candidates (polling every 1 second)
|
||||||
|
4. **connected** - Successfully connected!
|
||||||
|
5. **failed/closed** - Connection failed or was closed
|
||||||
|
|
||||||
### What Happens Under the Hood
|
### What Happens Under the Hood
|
||||||
|
|
||||||
1. **Offerer** calls `conn.createOffer()`:
|
1. **Offerer** calls `peer.createOffer()`:
|
||||||
- Creates RTCPeerConnection
|
- State → `creating-offer`
|
||||||
|
- Creates RTCPeerConnection and data channel
|
||||||
- Generates SDP offer
|
- Generates SDP offer
|
||||||
- Creates data channel
|
- Sets up ICE candidate handler (before gathering starts)
|
||||||
|
- Sets local description → ICE gathering begins
|
||||||
- Posts offer to Rondevu server
|
- Posts offer to Rondevu server
|
||||||
|
- State → `waiting-for-answer`
|
||||||
- Polls for answers every 2 seconds
|
- Polls for answers every 2 seconds
|
||||||
|
- When answer received → State → `exchanging-ice`
|
||||||
|
|
||||||
2. **Answerer** calls `conn.answer()`:
|
2. **Answerer** calls `peer.answer()`:
|
||||||
|
- State → `answering`
|
||||||
- Creates RTCPeerConnection
|
- Creates RTCPeerConnection
|
||||||
- Sets remote description (offer SDP)
|
- Sets remote description (offer SDP)
|
||||||
- Generates SDP answer
|
- Generates SDP answer
|
||||||
- Posts answer to server
|
- Sends answer to server (registers as answerer)
|
||||||
- Polls for ICE candidates every 1 second
|
- Sets up ICE candidate handler (before gathering starts)
|
||||||
|
- Sets local description → ICE gathering begins
|
||||||
|
- State → `exchanging-ice`
|
||||||
|
|
||||||
3. **ICE Exchange**:
|
3. **ICE Exchange** (Trickle ICE):
|
||||||
- Both peers generate ICE candidates
|
- Both peers generate ICE candidates as they're discovered
|
||||||
- Candidates are automatically sent to server
|
- Candidates are automatically sent to server immediately
|
||||||
- Peers poll and receive remote candidates
|
- Peers poll and receive remote candidates (every 1 second)
|
||||||
- ICE establishes the direct P2P path
|
- ICE establishes the direct P2P path
|
||||||
|
- State → `connected`
|
||||||
|
|
||||||
4. **Connection Established**:
|
4. **Connection Established**:
|
||||||
- Data channel opens
|
- Data channel opens
|
||||||
- Chat messages flow directly between peers
|
- Chat messages flow directly between peers
|
||||||
- No server relay (true P2P!)
|
- No server relay (true P2P!)
|
||||||
|
|
||||||
|
### Key Features of Implementation
|
||||||
|
|
||||||
|
- **Trickle ICE**: Candidates sent immediately as discovered (no waiting)
|
||||||
|
- **Proper Authorization**: Answer sent to server before ICE gathering to authorize candidate posting
|
||||||
|
- **Event Cleanup**: All event listeners properly removed with `removeEventListener`
|
||||||
|
- **State Management**: Clean state machine with well-defined transitions
|
||||||
|
- **Error Handling**: Graceful failure states with error events
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
- **Frontend**: React + Vite
|
- **Frontend**: React + Vite
|
||||||
- **Signaling**: Rondevu server (Cloudflare Workers + D1)
|
- **Signaling**: Rondevu server (Cloudflare Workers + D1)
|
||||||
- **Client**: @xtr-dev/rondevu-client (TypeScript library)
|
- **Client**: @xtr-dev/rondevu-client (TypeScript library)
|
||||||
- **WebRTC**: RTCPeerConnection with Google STUN servers
|
- **WebRTC**: RTCPeerConnection with STUN/TURN servers
|
||||||
|
- **Connection Management**: RondevuPeer class with state machine
|
||||||
|
|
||||||
## Server Configuration
|
## Server Configuration
|
||||||
|
|
||||||
@@ -213,18 +260,41 @@ npx wrangler pages deploy dist --project-name=rondevu-demo
|
|||||||
|
|
||||||
- Credentials are stored in localStorage and persist across sessions
|
- Credentials are stored in localStorage and persist across sessions
|
||||||
- Offers expire after 5 minutes by default
|
- Offers expire after 5 minutes by default
|
||||||
- The connection manager polls automatically (no manual polling needed)
|
- The peer automatically polls for answers and ICE candidates
|
||||||
- Multiple simultaneous connections are supported
|
- Multiple simultaneous connections are supported
|
||||||
- WebRTC uses Google's public STUN servers for NAT traversal
|
- WebRTC uses Google's public STUN servers + custom TURN server for NAT traversal
|
||||||
- Data channel messages are unreliable but fast (perfect for chat)
|
- Data channel messages are unreliable but fast (perfect for chat)
|
||||||
|
- Connection cleanup is automatic when peers disconnect
|
||||||
|
|
||||||
|
## Connection Timeouts
|
||||||
|
|
||||||
|
The demo uses these default timeouts:
|
||||||
|
|
||||||
|
- **ICE Gathering**: 10 seconds (not used with trickle ICE)
|
||||||
|
- **Waiting for Answer**: 30 seconds
|
||||||
|
- **Creating Answer**: 10 seconds
|
||||||
|
- **ICE Connection**: 30 seconds
|
||||||
|
|
||||||
|
These can be customized in the `PeerOptions`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await peer.createOffer({
|
||||||
|
topics: ['my-topic'],
|
||||||
|
timeouts: {
|
||||||
|
waitingForAnswer: 60000, // 1 minute
|
||||||
|
iceConnection: 45000 // 45 seconds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Technologies
|
## Technologies
|
||||||
|
|
||||||
- **React** - UI framework
|
- **React** - UI framework
|
||||||
- **Vite** - Build tool and dev server
|
- **Vite** - Build tool and dev server
|
||||||
- **@xtr-dev/rondevu-client** - Rondevu client library
|
- **@xtr-dev/rondevu-client** - Rondevu client library with `RondevuPeer`
|
||||||
- **RTCPeerConnection** - WebRTC connections
|
- **RTCPeerConnection** - WebRTC connections
|
||||||
- **RTCDataChannel** - P2P messaging
|
- **RTCDataChannel** - P2P messaging
|
||||||
|
- **QRCode** - QR code generation for easy topic sharing
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "rondevu-demo",
|
"name": "rondevu-demo",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rondevu-demo",
|
"name": "rondevu-demo",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xtr-dev/rondevu-client": "^0.4.1",
|
"@xtr-dev/rondevu-client": "^0.7.4",
|
||||||
"@zxing/library": "^0.21.3",
|
"@zxing/library": "^0.21.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -1171,9 +1171,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xtr-dev/rondevu-client": {
|
"node_modules/@xtr-dev/rondevu-client": {
|
||||||
"version": "0.4.1",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.7.4.tgz",
|
||||||
"integrity": "sha512-8giBS48thHKoIiqD6hD2VpMer50cGg4iwVMRCaaTiC7Ci6ICHXyCorNj6lWgw7dwL56oWhzbZU+cWHlQw2dxyQ==",
|
"integrity": "sha512-MPmw9iSc7LxLduu4TtVrcPvBl/Cuul5sqgOAKUWW7XYXYAObFYUtu9RcbWShR+a6Bwwx7oHadz5I2U8eWsebXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xtr-dev/rondevu-client": "^0.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xtr-dev/rondevu-client/node_modules/@xtr-dev/rondevu-client": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-110ejMCizPUPkHwwwNvcdCSZceLaHeFbf1LNkXvbG6pnLBqCf2uoGOOaRkArb7HNNFABFB+HXzm/AVzNdadosw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@zxing/library": {
|
"node_modules/@zxing/library": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rondevu-demo",
|
"name": "rondevu-demo",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"description": "Demo application for Rondevu topic-based peer discovery and signaling",
|
"description": "Demo application for Rondevu topic-based peer discovery and signaling",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
|
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xtr-dev/rondevu-client": "^0.4.1",
|
"@xtr-dev/rondevu-client": "^0.7.4",
|
||||||
"@zxing/library": "^0.21.3",
|
"@zxing/library": "^0.21.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
454
src/App.jsx
454
src/App.jsx
@@ -6,21 +6,20 @@ const API_URL = 'https://api.ronde.vu';
|
|||||||
|
|
||||||
const RTC_CONFIG = {
|
const RTC_CONFIG = {
|
||||||
iceServers: [
|
iceServers: [
|
||||||
// TCP transport to TURN server - VPN blocks UDP connections to TURN
|
|
||||||
{
|
{
|
||||||
urls: "turn:standard.relay.metered.ca:80?transport=tcp",
|
urls: ["stun:stun.ronde.vu:3478"]
|
||||||
username: "c53a9c971da5e6f3bc959d8d",
|
|
||||||
credential: "QaccPqtPPaxyokXp",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
urls: "turns:standard.relay.metered.ca:443?transport=tcp",
|
urls: [
|
||||||
username: "c53a9c971da5e6f3bc959d8d",
|
"turn:turn.ronde.vu:3478?transport=tcp",
|
||||||
credential: "QaccPqtPPaxyokXp",
|
"turn:turn.ronde.vu:3478?transport=udp",
|
||||||
},
|
],
|
||||||
|
username: "webrtcuser",
|
||||||
|
credential: "supersecretpassword"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
// Force relay to avoid direct connection attempts through VPN
|
// Force relay to test TURN server (comment out for normal operation)
|
||||||
iceTransportPolicy: 'relay',
|
// iceTransportPolicy: 'relay'
|
||||||
iceCandidatePoolSize: 10
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -34,8 +33,11 @@ export default function App() {
|
|||||||
const [myConnections, setMyConnections] = useState([]);
|
const [myConnections, setMyConnections] = useState([]);
|
||||||
|
|
||||||
// Discovery state
|
// Discovery state
|
||||||
const [searchTopic, setSearchTopic] = useState('demo-chat');
|
const [selectedTopic, setSelectedTopic] = useState(null);
|
||||||
const [discoveredOffers, setDiscoveredOffers] = useState([]);
|
const [discoveredOffers, setDiscoveredOffers] = useState([]);
|
||||||
|
const [topics, setTopics] = useState([]);
|
||||||
|
const [topicsLoading, setTopicsLoading] = useState(false);
|
||||||
|
const [topicsError, setTopicsError] = useState(null);
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
@@ -70,13 +72,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount only (empty dependency array)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
// Close all connections when component unmounts
|
// Close all peer connections when component unmounts
|
||||||
myConnections.forEach(c => c.conn?.close());
|
myConnections.forEach(c => c.peer?.close());
|
||||||
};
|
};
|
||||||
}, [myConnections]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Empty deps = only run on mount/unmount
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
@@ -94,7 +97,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create offer with connection manager
|
// Create offer with peer connection manager
|
||||||
const handleCreateOffer = async () => {
|
const handleCreateOffer = async () => {
|
||||||
if (!client || !credentials) {
|
if (!client || !credentials) {
|
||||||
toast.error('Please register first!');
|
toast.error('Please register first!');
|
||||||
@@ -104,65 +107,120 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const topics = offerTopics.split(',').map(t => t.trim()).filter(Boolean);
|
const topics = offerTopics.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
|
||||||
// Create connection using the manager
|
// Create peer connection using the manager
|
||||||
const conn = client.createConnection(RTC_CONFIG);
|
const peer = client.createPeer(RTC_CONFIG);
|
||||||
|
|
||||||
// Add debugging
|
// Add debugging
|
||||||
addApiLogging(client);
|
addApiLogging(client);
|
||||||
addIceLogging(conn);
|
addIceLogging(peer);
|
||||||
|
|
||||||
// Setup event listeners
|
// Setup event listeners
|
||||||
conn.on('connecting', () => {
|
peer.on('state', (state) => {
|
||||||
updateConnectionStatus(conn.id, 'connecting');
|
console.log(`🔄 Peer state: ${state}`);
|
||||||
|
updateConnectionStatus(peer.offerId, state);
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('connected', () => {
|
peer.on('connected', () => {
|
||||||
updateConnectionStatus(conn.id, 'connected');
|
updateConnectionStatus(peer.offerId, 'connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('disconnected', () => {
|
peer.on('disconnected', () => {
|
||||||
updateConnectionStatus(conn.id, 'disconnected');
|
updateConnectionStatus(peer.offerId, 'disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('datachannel', (channel) => {
|
peer.on('failed', (error) => {
|
||||||
|
console.error('❌ Peer connection failed:', error);
|
||||||
|
toast.error(`Connection failed: ${error.message}`);
|
||||||
|
updateConnectionStatus(peer.offerId, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('datachannel', (channel) => {
|
||||||
|
console.log(`📡 Data channel received, state: ${channel.readyState}`);
|
||||||
|
|
||||||
// Handle data channel
|
// Handle data channel
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
from: 'peer',
|
from: 'peer',
|
||||||
text: event.data,
|
text: event.data,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
connId: conn.id
|
connId: peer.offerId
|
||||||
}]);
|
}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateConnectionChannel(conn.id, channel);
|
channel.onopen = () => {
|
||||||
|
console.log(`✅ Data channel opened for offer ${peer.offerId}`);
|
||||||
|
updateConnectionChannel(peer.offerId, channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onerror = (error) => {
|
||||||
|
console.error('❌ Data channel error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onclose = () => {
|
||||||
|
console.log('🔒 Data channel closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
// If already open, update immediately
|
||||||
|
if (channel.readyState === 'open') {
|
||||||
|
updateConnectionChannel(peer.offerId, channel);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create offer
|
// Create offer
|
||||||
const offerId = await conn.createOffer({
|
const offerId = await peer.createOffer({
|
||||||
topics,
|
topics,
|
||||||
ttl: 300000
|
ttl: 300000,
|
||||||
|
timeouts: {
|
||||||
|
iceGathering: 15000,
|
||||||
|
waitingForAnswer: 60000,
|
||||||
|
iceConnection: 45000
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to connections list
|
// Add to connections list
|
||||||
setMyConnections(prev => [...prev, {
|
setMyConnections(prev => [...prev, {
|
||||||
id: offerId,
|
id: offerId,
|
||||||
topics,
|
topics,
|
||||||
status: 'waiting',
|
status: 'waiting-for-answer',
|
||||||
role: 'offerer',
|
role: 'offerer',
|
||||||
conn,
|
peer,
|
||||||
channel: conn.channel
|
channel: null
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
setOfferTopics('');
|
setOfferTopics('');
|
||||||
toast.success(`Created offer! Share topic "${topics[0]}" with peers.`);
|
toast.success(`Created offer! Share topic "${topics[0]}" with peers.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Error creating offer:', err);
|
||||||
toast.error(`Error: ${err.message}`);
|
toast.error(`Error: ${err.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Discover peers
|
// Fetch available topics from server
|
||||||
const handleDiscoverPeers = async () => {
|
const fetchTopics = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setTopicsLoading(true);
|
||||||
|
setTopicsError(null);
|
||||||
|
const result = await client.offers.getTopics({ limit: 100 });
|
||||||
|
setTopics(result.topics);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching topics:', err);
|
||||||
|
setTopicsError(err.message);
|
||||||
|
} finally {
|
||||||
|
setTopicsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch topics when discover tab is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'discover' && topics.length === 0 && !topicsLoading && client) {
|
||||||
|
fetchTopics();
|
||||||
|
}
|
||||||
|
}, [activeTab, client]);
|
||||||
|
|
||||||
|
// Discover peers by topic
|
||||||
|
const handleDiscoverPeers = async (topicName) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
if (!client.isAuthenticated()) {
|
if (!client.isAuthenticated()) {
|
||||||
@@ -171,13 +229,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const offers = await client.offers.findByTopic(searchTopic.trim(), {limit: 50});
|
setSelectedTopic(topicName);
|
||||||
|
const offers = await client.offers.findByTopic(topicName, {limit: 50});
|
||||||
setDiscoveredOffers(offers);
|
setDiscoveredOffers(offers);
|
||||||
|
|
||||||
if (offers.length === 0) {
|
if (offers.length === 0) {
|
||||||
toast.error('No peers found!');
|
toast(`No peers found for "${topicName}"`);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Found ${offers.length} peer(s)`);
|
toast.success(`Found ${offers.length} peer(s) for "${topicName}"`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Error: ${err.message}`);
|
toast.error(`Error: ${err.message}`);
|
||||||
@@ -192,56 +251,89 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create connection using the manager
|
// Create peer connection using the manager
|
||||||
const conn = client.createConnection(RTC_CONFIG);
|
const peer = client.createPeer(RTC_CONFIG);
|
||||||
|
|
||||||
// Add debugging
|
// Add debugging
|
||||||
addApiLogging(client);
|
addApiLogging(client);
|
||||||
addIceLogging(conn);
|
addIceLogging(peer);
|
||||||
|
|
||||||
// Setup event listeners
|
// Setup event listeners
|
||||||
conn.on('connecting', () => {
|
peer.on('state', (state) => {
|
||||||
updateConnectionStatus(conn.id, 'connecting');
|
console.log(`🔄 Peer state: ${state}`);
|
||||||
|
updateConnectionStatus(offer.id, state);
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('connected', () => {
|
peer.on('connected', () => {
|
||||||
updateConnectionStatus(conn.id, 'connected');
|
updateConnectionStatus(offer.id, 'connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('disconnected', () => {
|
peer.on('disconnected', () => {
|
||||||
updateConnectionStatus(conn.id, 'disconnected');
|
updateConnectionStatus(offer.id, 'disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on('datachannel', (channel) => {
|
peer.on('failed', (error) => {
|
||||||
|
console.error('❌ Peer connection failed:', error);
|
||||||
|
toast.error(`Connection failed: ${error.message}`);
|
||||||
|
updateConnectionStatus(offer.id, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('datachannel', (channel) => {
|
||||||
|
console.log(`📡 Data channel received, state: ${channel.readyState}`);
|
||||||
|
|
||||||
// Handle data channel
|
// Handle data channel
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
from: 'peer',
|
from: 'peer',
|
||||||
text: event.data,
|
text: event.data,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
connId: conn.id
|
connId: offer.id
|
||||||
}]);
|
}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateConnectionChannel(conn.id, channel);
|
channel.onopen = () => {
|
||||||
|
console.log(`✅ Data channel opened for offer ${offer.id}`);
|
||||||
|
updateConnectionChannel(offer.id, channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onerror = (error) => {
|
||||||
|
console.error('❌ Data channel error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onclose = () => {
|
||||||
|
console.log('🔒 Data channel closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
// If already open, update immediately
|
||||||
|
if (channel.readyState === 'open') {
|
||||||
|
updateConnectionChannel(offer.id, channel);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Answer the offer
|
// Answer the offer
|
||||||
await conn.answer(offer.id, offer.sdp);
|
await peer.answer(offer.id, offer.sdp, {
|
||||||
|
topics: offer.topics,
|
||||||
|
timeouts: {
|
||||||
|
iceGathering: 15000,
|
||||||
|
creatingAnswer: 15000,
|
||||||
|
iceConnection: 45000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add to connections list
|
// Add to connections list
|
||||||
setMyConnections(prev => [...prev, {
|
setMyConnections(prev => [...prev, {
|
||||||
id: offer.id,
|
id: offer.id,
|
||||||
topics: offer.topics,
|
topics: offer.topics,
|
||||||
status: 'connecting',
|
status: 'answering',
|
||||||
role: 'answerer',
|
role: 'answerer',
|
||||||
conn,
|
peer,
|
||||||
channel: null
|
channel: null
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
setActiveTab('connections');
|
setActiveTab('connections');
|
||||||
toast.success('Connecting...');
|
toast.success('Answering offer...');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Error answering offer:', err);
|
||||||
toast.error(`Error: ${err.message}`);
|
toast.error(`Error: ${err.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -266,26 +358,43 @@ export default function App() {
|
|||||||
|
|
||||||
client.offers.addIceCandidates = async (offerId, candidates) => {
|
client.offers.addIceCandidates = async (offerId, candidates) => {
|
||||||
console.log(`📤 Sending ${candidates.length} ICE candidate(s) to server for offer ${offerId}`);
|
console.log(`📤 Sending ${candidates.length} ICE candidate(s) to server for offer ${offerId}`);
|
||||||
return originalAddIceCandidates(offerId, candidates);
|
console.log(`📤 Candidates:`, candidates);
|
||||||
|
const result = await originalAddIceCandidates(offerId, candidates);
|
||||||
|
console.log(`📤 Send result:`, result);
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
client.offers.getIceCandidates = async (offerId, since) => {
|
client.offers.getIceCandidates = async (offerId, since) => {
|
||||||
const result = await originalGetIceCandidates(offerId, since);
|
const result = await originalGetIceCandidates(offerId, since);
|
||||||
console.log(`📥 Received ${result.length} ICE candidate(s) from server for offer ${offerId}, since=${since}`);
|
console.log(`📥 Received ${result.length} ICE candidate(s) from server for offer ${offerId}, since=${since}`);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
console.log(`📥 First candidate:`, result[0]);
|
console.log(`📥 All candidates:`, result);
|
||||||
|
result.forEach((cand, i) => {
|
||||||
|
console.log(`📥 Candidate ${i}:`, {
|
||||||
|
role: cand.role,
|
||||||
|
peerId: cand.peerId,
|
||||||
|
candidate: cand.candidate,
|
||||||
|
createdAt: cand.createdAt
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add ICE debugging to a connection
|
// Add ICE debugging to a peer connection
|
||||||
const addIceLogging = (conn) => {
|
const addIceLogging = (peer) => {
|
||||||
const pc = conn['pc']; // Access underlying peer connection for debugging
|
const pc = peer.pc; // Access underlying peer connection for debugging
|
||||||
if (pc) {
|
if (pc) {
|
||||||
// Add new handlers that don't override existing ones
|
// Add new handlers that don't override existing ones
|
||||||
pc.addEventListener('icecandidate', (event) => {
|
pc.addEventListener('icecandidate', (event) => {
|
||||||
if (event.candidate) {
|
if (event.candidate) {
|
||||||
|
// Skip empty/end-of-candidates markers in logs
|
||||||
|
if (!event.candidate.candidate || event.candidate.candidate === '') {
|
||||||
|
console.log('🧊 ICE gathering complete (end-of-candidates marker)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🧊 ICE candidate gathered:', {
|
console.log('🧊 ICE candidate gathered:', {
|
||||||
type: event.candidate.type,
|
type: event.candidate.type,
|
||||||
protocol: event.candidate.protocol,
|
protocol: event.candidate.protocol,
|
||||||
@@ -304,10 +413,67 @@ export default function App() {
|
|||||||
|
|
||||||
pc.addEventListener('iceconnectionstatechange', () => {
|
pc.addEventListener('iceconnectionstatechange', () => {
|
||||||
console.log('🧊 ICE connection state:', pc.iceConnectionState);
|
console.log('🧊 ICE connection state:', pc.iceConnectionState);
|
||||||
|
if (pc.iceConnectionState === 'failed') {
|
||||||
|
console.error('❌ ICE connection failed! Check firewall/NAT/TURN server.');
|
||||||
|
// Log stats when failed
|
||||||
|
pc.getStats().then(stats => {
|
||||||
|
console.log('📊 Connection stats at failure:', stats);
|
||||||
|
});
|
||||||
|
} else if (pc.iceConnectionState === 'checking') {
|
||||||
|
console.log('⏳ ICE checking candidates...');
|
||||||
|
// Set a timeout to detect if we're stuck
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pc.iceConnectionState === 'checking') {
|
||||||
|
console.warn('⚠️ Still in checking state after 30s - connection may be stuck');
|
||||||
|
pc.getStats().then(stats => {
|
||||||
|
stats.forEach(report => {
|
||||||
|
if (report.type === 'candidate-pair') {
|
||||||
|
console.log('Candidate pair:', report);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
pc.addEventListener('connectionstatechange', () => {
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
console.log('🔌 Connection state:', pc.connectionState);
|
console.log('🔌 Connection state:', pc.connectionState);
|
||||||
|
if (pc.connectionState === 'failed') {
|
||||||
|
console.error('❌ Connection failed!');
|
||||||
|
// Log the selected candidate pair to see what was attempted
|
||||||
|
pc.getStats().then(stats => {
|
||||||
|
stats.forEach(report => {
|
||||||
|
if (report.type === 'candidate-pair' && report.selected) {
|
||||||
|
console.log('Selected candidate pair:', report);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log ICE candidate pair changes
|
||||||
|
pc.addEventListener('icecandidate', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
pc.getStats().then(stats => {
|
||||||
|
stats.forEach(report => {
|
||||||
|
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
||||||
|
console.log('✅ ICE candidate pair succeeded:', {
|
||||||
|
local: report.localCandidateId,
|
||||||
|
remote: report.remoteCandidateId,
|
||||||
|
nominated: report.nominated,
|
||||||
|
state: report.state
|
||||||
|
});
|
||||||
|
} else if (report.type === 'candidate-pair' && report.state === 'failed') {
|
||||||
|
console.log('❌ ICE candidate pair failed:', {
|
||||||
|
local: report.localCandidateId,
|
||||||
|
remote: report.remoteCandidateId,
|
||||||
|
state: report.state
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -336,7 +502,7 @@ export default function App() {
|
|||||||
localStorage.removeItem('rondevu-credentials');
|
localStorage.removeItem('rondevu-credentials');
|
||||||
setCredentials(null);
|
setCredentials(null);
|
||||||
setStatus('Not registered');
|
setStatus('Not registered');
|
||||||
myConnections.forEach(c => c.conn?.close());
|
myConnections.forEach(c => c.peer?.close());
|
||||||
setMyConnections([]);
|
setMyConnections([]);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setClient(new Rondevu({baseUrl: API_URL}));
|
setClient(new Rondevu({baseUrl: API_URL}));
|
||||||
@@ -350,7 +516,7 @@ export default function App() {
|
|||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<h1 style={styles.title}>🌐 Rondevu</h1>
|
<h1 style={styles.title}>🌐 Rondevu</h1>
|
||||||
<p style={styles.subtitle}>Topic-Based Peer Discovery & WebRTC</p>
|
<p style={styles.subtitle}>Topic-Based Peer Discovery & WebRTC</p>
|
||||||
<p style={styles.version}>v0.4.0 - With Connection Manager</p>
|
<p style={styles.version}>v0.5.0 - State-Based Peer Manager</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
@@ -462,52 +628,122 @@ export default function App() {
|
|||||||
{activeTab === 'discover' && (
|
{activeTab === 'discover' && (
|
||||||
<div>
|
<div>
|
||||||
<h2>Discover Peers</h2>
|
<h2>Discover Peers</h2>
|
||||||
<p style={styles.desc}>Search for peers by topic</p>
|
<p style={styles.desc}>Browse topics to find peers</p>
|
||||||
|
|
||||||
<div style={{marginBottom: '20px'}}>
|
{!selectedTopic ? (
|
||||||
<label style={styles.label}>Topic:</label>
|
|
||||||
<div style={{display: 'flex', gap: '10px'}}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchTopic}
|
|
||||||
onChange={(e) => setSearchTopic(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleDiscoverPeers()}
|
|
||||||
style={{...styles.input, flex: 1}}
|
|
||||||
/>
|
|
||||||
<button onClick={handleDiscoverPeers} style={styles.btnPrimary}>
|
|
||||||
🔍 Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{discoveredOffers.length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Found {discoveredOffers.length} Peer(s)</h3>
|
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px'}}>
|
||||||
{discoveredOffers.map(offer => {
|
<h3>Active Topics ({topics.length})</h3>
|
||||||
const isConnected = myConnections.some(c => c.id === offer.id);
|
<button onClick={fetchTopics} style={styles.btnSecondary} disabled={topicsLoading}>
|
||||||
const isMine = credentials && offer.peerId === credentials.peerId;
|
{topicsLoading ? '⟳ Loading...' : '🔄 Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{topicsLoading ? (
|
||||||
<div key={offer.id} style={styles.card}>
|
<div style={{textAlign: 'center', padding: '60px', color: '#999'}}>
|
||||||
<div style={{marginBottom: '10px'}}>
|
<div style={{fontSize: '3em', marginBottom: '10px'}}>⟳</div>
|
||||||
<div style={{fontWeight: '600'}}>{offer.topics.join(', ')}</div>
|
<div>Loading topics...</div>
|
||||||
<div style={{fontSize: '0.85em', color: '#666'}}>
|
</div>
|
||||||
Peer: {offer.peerId.substring(0, 16)}...
|
) : topicsError ? (
|
||||||
|
<div style={{textAlign: 'center', padding: '40px'}}>
|
||||||
|
<div style={{...styles.card, background: '#ffebee', color: '#c62828', border: '2px solid #ef9a9a'}}>
|
||||||
|
<div style={{fontSize: '2em', marginBottom: '10px'}}>⚠️</div>
|
||||||
|
<div style={{fontWeight: '600', marginBottom: '5px'}}>Failed to load topics</div>
|
||||||
|
<div style={{fontSize: '0.9em'}}>{topicsError}</div>
|
||||||
|
<button onClick={fetchTopics} style={{...styles.btnPrimary, marginTop: '15px'}}>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : topics.length === 0 ? (
|
||||||
|
<div style={{textAlign: 'center', padding: '60px', color: '#999'}}>
|
||||||
|
<div style={{fontSize: '3em', marginBottom: '10px'}}>📭</div>
|
||||||
|
<div style={{fontWeight: '600', marginBottom: '5px'}}>No active topics</div>
|
||||||
|
<div style={{fontSize: '0.9em'}}>Create an offer to start a new topic</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={styles.topicsGrid}>
|
||||||
|
{topics.map(topic => (
|
||||||
|
<div
|
||||||
|
key={topic.topic}
|
||||||
|
className="topic-card-hover"
|
||||||
|
onClick={() => handleDiscoverPeers(topic.topic)}
|
||||||
|
>
|
||||||
|
<div style={{fontSize: '2.5em', marginBottom: '10px'}}>💬</div>
|
||||||
|
<div style={{fontWeight: '600', marginBottom: '5px', wordBreak: 'break-word'}}>{topic.topic}</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85em',
|
||||||
|
color: '#667eea',
|
||||||
|
fontWeight: '600',
|
||||||
|
background: '#f0f2ff',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: '5px'
|
||||||
|
}}>
|
||||||
|
{topic.activePeers} {topic.activePeers === 1 ? 'peer' : 'peers'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{marginBottom: '20px'}}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTopic(null);
|
||||||
|
setDiscoveredOffers([]);
|
||||||
|
fetchTopics(); // Refresh topics when going back
|
||||||
|
}}
|
||||||
|
style={{...styles.btnSecondary, marginBottom: '10px'}}
|
||||||
|
>
|
||||||
|
← Back to Topics
|
||||||
|
</button>
|
||||||
|
<h3>Topic: {selectedTopic}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isMine ? (
|
{discoveredOffers.length > 0 ? (
|
||||||
<div style={{...styles.badge, background: '#2196f3'}}>Your offer</div>
|
<div>
|
||||||
) : isConnected ? (
|
<p style={{marginBottom: '15px', color: '#666'}}>
|
||||||
<div style={{...styles.badge, background: '#4caf50'}}>✓ Connected</div>
|
Found {discoveredOffers.length} peer(s)
|
||||||
) : (
|
</p>
|
||||||
<button onClick={() => handleAnswerOffer(offer)} style={styles.btnSuccess}>
|
{discoveredOffers.map(offer => {
|
||||||
🤝 Connect
|
const isConnected = myConnections.some(c => c.id === offer.id);
|
||||||
</button>
|
const isMine = credentials && offer.peerId === credentials.peerId;
|
||||||
)}
|
|
||||||
|
return (
|
||||||
|
<div key={offer.id} style={styles.card}>
|
||||||
|
<div style={{marginBottom: '10px'}}>
|
||||||
|
<div style={{fontWeight: '600'}}>{offer.topics.join(', ')}</div>
|
||||||
|
<div style={{fontSize: '0.85em', color: '#666'}}>
|
||||||
|
Peer: {offer.peerId.substring(0, 16)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMine ? (
|
||||||
|
<div style={{...styles.badge, background: '#2196f3'}}>Your offer</div>
|
||||||
|
) : isConnected ? (
|
||||||
|
<div style={{...styles.badge, background: '#4caf50'}}>✓ Connected</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => handleAnswerOffer(offer)} style={styles.btnSuccess}>
|
||||||
|
🤝 Connect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{textAlign: 'center', padding: '40px', color: '#999'}}>
|
||||||
|
<div style={{fontSize: '3em'}}>🔍</div>
|
||||||
|
<div>No peers available for this topic</div>
|
||||||
|
<div style={{fontSize: '0.9em', marginTop: '10px'}}>
|
||||||
|
Try creating an offer or check back later
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -720,6 +956,16 @@ const styles = {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
width: '100%'
|
width: '100%'
|
||||||
},
|
},
|
||||||
|
btnSecondary: {
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
color: '#333',
|
||||||
|
border: '2px solid #e0e0e0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.95em',
|
||||||
|
fontWeight: '600'
|
||||||
|
},
|
||||||
btnDanger: {
|
btnDanger: {
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
background: '#f44336',
|
background: '#f44336',
|
||||||
@@ -757,5 +1003,11 @@ const styles = {
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
fontSize: '0.9em'
|
fontSize: '0.9em'
|
||||||
|
},
|
||||||
|
topicsGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
|
gap: '15px',
|
||||||
|
marginTop: '20px'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -997,3 +997,22 @@ input[type="text"]:disabled {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(85, 104, 211, 0.4);
|
box-shadow: 0 6px 16px rgba(85, 104, 211, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Topic cards grid for discovery */
|
||||||
|
.topic-card-hover {
|
||||||
|
padding: 25px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card-hover:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #fafbfc;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user