9 Commits

Author SHA1 Message Date
6a24514e7b Fix signature validation issues in demo
- Add comprehensive logging for init and publish flows
- Verify username is claimed before publishing service
- Detect keypair mismatches and provide clear error messages
- Handle authentication errors more gracefully
- Auto-claim username if not claimed during publish
- Improved user guidance for common errors
2025-12-09 22:54:31 +01:00
46f0eb2e7a Restore full-featured chat UI with contact management, multiple chats, and RTC presets
- Restored contact management (add/remove friends)
- Restored multiple concurrent chat support
- Restored RTC configuration presets (ipv4-turn, hostname-turns, google-stun, relay-only, custom)
- Restored settings modal
- Restored dark theme styling
- Restored online status indicators
- Adapted all features to work with new unified Rondevu API
- Manual RTCPeerConnection management instead of ServiceHost/ServiceClient
- Manual offer pooling (10 concurrent connections)
- Manual ICE candidate polling
2025-12-09 22:49:11 +01:00
9e26ed3b66 Update to use local client package for development
- Install client from local path instead of npm registry
- Workaround for npm registry propagation delay

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 22:40:41 +01:00
7835ebd35d v2.1.0: Rewrite to use simplified Rondevu API
- Complete rewrite to use low-level Rondevu API directly
- Removed ServiceHost/ServiceClient abstractions
- Manual RTCPeerConnection and data channel setup
- Custom polling for answers and ICE candidates
- Updated to use new Rondevu class instead of RondevuService
- Direct signaling method calls instead of getAPI()
- Reduced from 926 lines to 542 lines (42% reduction)
- Demonstrates complete WebRTC flow with clear offerer/answerer roles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 22:23:04 +01:00
5f223356ba feat: improve login persistence with server verification
- Properly await isUsernameClaimed() check during initialization
- Verify saved username is still valid on the server
- Show 'Welcome back' toast when restoring session
- Handle expired usernames gracefully
2025-12-08 21:39:58 +01:00
ab55a96fac fix: add missing ServiceHost and ServiceClient imports
- Import ServiceHost and ServiceClient from @xtr-dev/rondevu-client
- Fixes ReferenceError when starting hosting
2025-12-08 21:37:03 +01:00
158e001055 chore: update rondevu-client to v0.12.0 2025-12-07 22:23:13 +01:00
9967e8d762 Refactor demo to use new ServiceHost and ServiceClient API
- Update to @xtr-dev/rondevu-client v0.10.1
- Replace Rondevu class with RondevuService, ServiceHost, ServiceClient
- Simplify demo implementation (removed complex features)
- Use new event-driven API for connections
- Support custom RTC configuration
- Backup old implementation as App-old.jsx

Breaking changes from v1 API:
- RondevuService for username claiming
- ServiceHost for hosting services with offer pool
- ServiceClient for connecting with auto-reconnection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 19:46:07 +01:00
8c3f21f262 Update @xtr-dev/rondevu-client to v0.10.0
- ServiceHost: Manages offer pool for hosting services
- ServiceClient: Connects to hosted services with auto-reconnection
- RondevuService: High-level API for username claiming and service publishing

Note: Demo code still uses old Rondevu API and needs refactoring

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 19:39:54 +01:00
4 changed files with 1773 additions and 294 deletions

41
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "rondevu-demo", "name": "rondevu-demo",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@xtr-dev/rondevu-client": "^0.9.2", "@xtr-dev/rondevu-client": "file:../client",
"@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",
@@ -22,6 +22,27 @@
"vite": "^5.4.11" "vite": "^5.4.11"
} }
}, },
"../client": {
"name": "@xtr-dev/rondevu-client",
"version": "0.13.0",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -745,15 +766,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@noble/ed25519": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
"integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1171,13 +1183,8 @@
} }
}, },
"node_modules/@xtr-dev/rondevu-client": { "node_modules/@xtr-dev/rondevu-client": {
"version": "0.9.2", "resolved": "../client",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.9.2.tgz", "link": true
"integrity": "sha512-DVow5AOPU40dqQtlfQK7J2GNX8dz2/4UzltMqublaPZubbkRYgocvp0b76NQu5F6v150IstMV2N49uxAYqogVw==",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
}, },
"node_modules/@zxing/library": { "node_modules/@zxing/library": {
"version": "0.21.3", "version": "0.21.3",

View File

@@ -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.9.2", "@xtr-dev/rondevu-client": "file:../client",
"@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",

1368
src/App-old.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { Rondevu } from '@xtr-dev/rondevu-client';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
const API_URL = 'https://api.ronde.vu'; const API_URL = 'https://api.ronde.vu';
const CHAT_SERVICE = 'chat:2.0.0';
// Preset RTC configurations // Preset RTC configurations
const RTC_PRESETS = { const RTC_PRESETS = {
@@ -73,12 +74,11 @@ const RTC_PRESETS = {
}; };
export default function App() { export default function App() {
const [client, setClient] = useState(null); const [rondevu, setRondevu] = useState(null);
const [credentials, setCredentials] = useState(null);
const [myUsername, setMyUsername] = useState(null); const [myUsername, setMyUsername] = useState(null);
// Setup // Setup
const [setupStep, setSetupStep] = useState('register'); // register, claim, ready const [setupStep, setSetupStep] = useState('init'); // init, claim, ready
const [usernameInput, setUsernameInput] = useState(''); const [usernameInput, setUsernameInput] = useState('');
// Contacts // Contacts
@@ -86,13 +86,15 @@ export default function App() {
const [contactInput, setContactInput] = useState(''); const [contactInput, setContactInput] = useState('');
const [onlineUsers, setOnlineUsers] = useState(new Set()); const [onlineUsers, setOnlineUsers] = useState(new Set());
// Chat // Chat - structure: { [username]: { connection, channel, messages, status, role, serviceFqn, offerId, polling } }
const [activeChats, setActiveChats] = useState({}); const [activeChats, setActiveChats] = useState({});
const [selectedChat, setSelectedChat] = useState(null); const [selectedChat, setSelectedChat] = useState(null);
const [messageInputs, setMessageInputs] = useState({}); const [messageInputs, setMessageInputs] = useState({});
// Service // Service - we publish one service that can accept multiple connections
const [serviceHandle, setServiceHandle] = useState(null); const [myServicePublished, setMyServicePublished] = useState(false);
const [hostConnections, setHostConnections] = useState({}); // Track incoming connections as host
const chatEndRef = useRef(null); const chatEndRef = useRef(null);
// Settings // Settings
@@ -137,16 +139,18 @@ export default function App() {
} }
}, [customRtcConfig]); }, [customRtcConfig]);
// Load saved data and auto-register // Initialize Rondevu
useEffect(() => { useEffect(() => {
const savedCreds = localStorage.getItem('rondevu-chat-credentials'); const init = async () => {
const savedUsername = localStorage.getItem('rondevu-chat-username'); try {
const savedContacts = localStorage.getItem('rondevu-chat-contacts'); const savedUsername = localStorage.getItem('rondevu-username');
const savedKeypair = localStorage.getItem('rondevu-keypair');
const savedContacts = localStorage.getItem('rondevu-contacts');
const initialize = async () => { console.log('[Init] Saved username:', savedUsername);
let clientInstance; console.log('[Init] Has saved keypair:', !!savedKeypair);
// Load contacts first // Load contacts
if (savedContacts) { if (savedContacts) {
try { try {
setContacts(JSON.parse(savedContacts)); setContacts(JSON.parse(savedContacts));
@@ -155,56 +159,41 @@ export default function App() {
} }
} }
// Handle credentials const parsedKeypair = savedKeypair ? JSON.parse(savedKeypair) : undefined;
if (savedCreds) {
try {
const creds = JSON.parse(savedCreds);
setCredentials(creds);
clientInstance = new Rondevu({ baseUrl: API_URL, credentials: creds });
setClient(clientInstance);
// If we have username too, go straight to ready const service = new Rondevu({
if (savedUsername) { apiUrl: API_URL,
username: savedUsername || 'temp',
keypair: parsedKeypair,
});
await service.initialize();
setRondevu(service);
if (savedUsername && savedKeypair) {
console.log('[Init] Checking if username is claimed...');
const isClaimed = await service.isUsernameClaimed();
console.log('[Init] Username claimed:', isClaimed);
if (isClaimed) {
setMyUsername(savedUsername); setMyUsername(savedUsername);
setSetupStep('ready'); setSetupStep('ready');
toast.success(`Welcome back, ${savedUsername}!`);
} else {
console.warn('[Init] Username not claimed on server, need to claim');
setSetupStep('claim');
}
} else { } else {
// Have creds but no username - go to claim step
setSetupStep('claim'); setSetupStep('claim');
} }
} catch (err) { } catch (err) {
console.error('Failed to load credentials:', err); console.error('Initialization failed:', err);
// Invalid saved creds - auto-register toast.error(`Failed to initialize: ${err.message}`);
clientInstance = new Rondevu({ baseUrl: API_URL });
setClient(clientInstance);
await autoRegister(clientInstance);
}
} else {
// No saved credentials - auto-register
console.log('No saved credentials, auto-registering...');
clientInstance = new Rondevu({ baseUrl: API_URL });
setClient(clientInstance);
await autoRegister(clientInstance);
}
};
const autoRegister = async (clientInstance) => {
try {
console.log('Starting auto-registration...');
const creds = await clientInstance.register();
console.log('Registration successful:', creds);
setCredentials(creds);
localStorage.setItem('rondevu-chat-credentials', JSON.stringify(creds));
const newClient = new Rondevu({ baseUrl: API_URL, credentials: creds });
setClient(newClient);
setSetupStep('claim'); setSetupStep('claim');
} catch (err) {
console.error('Auto-registration failed:', err);
toast.error(`Registration failed: ${err.message}`);
setSetupStep('claim'); // Still allow username claim, might work anyway
} }
}; };
initialize(); init();
}, []); }, []);
// Auto-scroll chat // Auto-scroll chat
@@ -212,35 +201,26 @@ export default function App() {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [activeChats, selectedChat]); }, [activeChats, selectedChat]);
// Start chat service when ready // Publish service when ready
useEffect(() => { useEffect(() => {
if (setupStep === 'ready' && myUsername && client && !serviceHandle) { if (setupStep === 'ready' && myUsername && rondevu && !myServicePublished) {
startChatService(); publishMyService();
} }
}, [setupStep, myUsername, client]); }, [setupStep, myUsername, rondevu, myServicePublished]);
// Check online status periodically // Check online status periodically
// Note: Online detection by attempting to query services
// In v0.9.0 there's no direct listServices API, so we check by attempting connection
useEffect(() => { useEffect(() => {
if (setupStep !== 'ready' || !client) return; if (setupStep !== 'ready' || !rondevu) return;
const checkOnlineStatus = async () => { const checkOnlineStatus = async () => {
const online = new Set(); const online = new Set();
for (const contact of contacts) { for (const contact of contacts) {
try { try {
// Try to query the service via discovery endpoint const fqn = `${CHAT_SERVICE}@${contact}`;
const response = await fetch(`${API_URL}/index/${contact}/query`, { await rondevu.getService(fqn);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceFqn: 'chat.rondevu@1.0.0' })
});
if (response.ok) {
online.add(contact); online.add(contact);
}
} catch (err) { } catch (err) {
// User offline or doesn't exist // User offline or doesn't have service published
} }
} }
setOnlineUsers(online); setOnlineUsers(online);
@@ -250,16 +230,27 @@ export default function App() {
const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s
return () => clearInterval(interval); return () => clearInterval(interval);
}, [contacts, setupStep, client]); }, [contacts, setupStep, rondevu]);
// Claim username // Claim username
const handleClaimUsername = async () => { const handleClaimUsername = async () => {
if (!client || !usernameInput) return; if (!rondevu || !usernameInput) return;
try { try {
const claim = await client.usernames.claimUsername(usernameInput); const keypair = rondevu.getKeypair();
client.usernames.saveKeypairToStorage(usernameInput, claim.publicKey, claim.privateKey); const newService = new Rondevu({
apiUrl: API_URL,
username: usernameInput,
keypair,
});
await newService.initialize();
await newService.claimUsername();
setRondevu(newService);
setMyUsername(usernameInput); setMyUsername(usernameInput);
localStorage.setItem('rondevu-chat-username', usernameInput); localStorage.setItem('rondevu-username', usernameInput);
localStorage.setItem('rondevu-keypair', JSON.stringify(keypair));
setSetupStep('ready'); setSetupStep('ready');
toast.success(`Welcome, ${usernameInput}!`); toast.success(`Welcome, ${usernameInput}!`);
} catch (err) { } catch (err) {
@@ -267,122 +258,167 @@ export default function App() {
} }
}; };
// Start pooled chat service with durable connections // Publish service to accept incoming connections
const startChatService = async () => { const publishMyService = async () => {
if (!client || !myUsername || serviceHandle) return;
try { try {
const keypair = client.usernames.loadKeypairFromStorage(myUsername); // Verify username is claimed with correct keypair before publishing
if (!keypair) { console.log('[Publish] Verifying username claim...');
toast.error('Username keypair not found'); const isClaimed = await rondevu.isUsernameClaimed();
console.log('[Publish] Is claimed by us:', isClaimed);
if (!isClaimed) {
console.warn('[Publish] Username not claimed by current keypair');
// Check if username is claimed by someone else
try {
const usernameCheck = await fetch(`${API_URL}/users/${myUsername}`);
const checkData = await usernameCheck.json();
if (!checkData.available) {
// Username claimed by different keypair
console.error('[Publish] Username claimed by different keypair');
toast.error(
'Username keypair mismatch. Please logout and try again with a fresh account.',
{ duration: 10000 }
);
return; return;
} }
} catch (e) {
console.error('[Publish] Failed to check username:', e);
}
const service = await client.exposeService({ // Try to claim username
username: myUsername, console.log('[Publish] Attempting to claim username...');
privateKey: keypair.privateKey, toast.loading('Claiming username...', { id: 'claim' });
serviceFqn: 'chat.rondevu@1.0.0',
isPublic: true,
ttl: 300000, // 5 minutes - service auto-refreshes
ttlRefreshMargin: 0.2, // Refresh at 80% of TTL
poolSize: 10, // Support up to 10 simultaneous connections
rtcConfig: getCurrentRtcConfig(),
handler: (channel, connectionId) => {
console.log(`📡 New chat connection: ${connectionId}`);
// Wait for peer to identify themselves
channel.on('message', (data) => {
try { try {
const msg = JSON.parse(data); await rondevu.claimUsername();
toast.success('Username claimed!', { id: 'claim' });
} catch (claimErr) {
console.error('[Publish] Failed to claim username:', claimErr);
toast.error(`Failed to claim username: ${claimErr.message}`, { id: 'claim' });
return;
}
}
// We'll create a pool of offers manually
const offers = [];
const poolSize = 10; // Support up to 10 simultaneous connections
console.log('[Publish] Creating', poolSize, 'peer connections...');
for (let i = 0; i < poolSize; i++) {
const pc = new RTCPeerConnection(getCurrentRtcConfig());
const dc = pc.createDataChannel('chat');
// Setup handlers
setupHostConnection(pc, dc, myUsername);
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
offers.push({ sdp: offer.sdp });
// Store connection for later
setHostConnections(prev => ({
...prev,
[`host-${i}`]: { pc, dc, status: 'waiting' }
}));
}
// Publish service
const fqn = `${CHAT_SERVICE}@${myUsername}`;
console.log('[Publish] Publishing service with FQN:', fqn);
console.log('[Publish] Public key:', rondevu.getPublicKey());
await rondevu.publishService({
serviceFqn: fqn,
offers,
ttl: 300000, // 5 minutes
});
setMyServicePublished(true);
console.log('✅ Chat service published successfully with', poolSize, 'offers');
toast.success('Chat service started!');
} catch (err) {
console.error('[Publish] Failed to publish service:', err);
toast.error(`Failed to start chat service: ${err.message}`);
// Provide helpful guidance based on error type
if (err.message?.includes('Invalid signature') || err.message?.includes('403')) {
toast.error(
'Authentication error. Try logging out and creating a new account.',
{ duration: 10000 }
);
}
}
};
// Setup host connection (when someone connects to us)
const setupHostConnection = (pc, dc, hostUsername) => {
let peerUsername = null;
dc.onopen = () => {
console.log('Host data channel opened');
};
dc.onclose = () => {
console.log('Host data channel closed');
if (peerUsername) {
setActiveChats(prev => ({
...prev,
[peerUsername]: { ...prev[peerUsername], status: 'disconnected' }
}));
}
};
dc.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'identify') { if (msg.type === 'identify') {
// Peer identified themselves // Peer identified themselves
peerUsername = msg.from;
console.log(`📡 New chat connection from: ${peerUsername}`);
setActiveChats(prev => ({ setActiveChats(prev => ({
...prev, ...prev,
[msg.from]: { [peerUsername]: {
username: msg.from, username: peerUsername,
channel, channel: dc,
connectionId, connection: pc,
messages: prev[msg.from]?.messages || [], messages: prev[peerUsername]?.messages || [],
status: 'connected' status: 'connected',
role: 'host'
} }
})); }));
// Remove old handler and add new one for chat messages // Send acknowledgment
channel.off('message'); dc.send(JSON.stringify({
channel.on('message', (chatData) => { type: 'identify_ack',
try { from: hostUsername
const chatMsg = JSON.parse(chatData); }));
if (chatMsg.type === 'message') { } else if (msg.type === 'message' && peerUsername) {
// Chat message
setActiveChats(prev => ({ setActiveChats(prev => ({
...prev, ...prev,
[msg.from]: { [peerUsername]: {
...prev[msg.from], ...prev[peerUsername],
messages: [...(prev[msg.from]?.messages || []), { messages: [...(prev[peerUsername]?.messages || []), {
from: msg.from, from: peerUsername,
text: chatMsg.text, text: msg.text,
timestamp: Date.now() timestamp: Date.now()
}] }]
} }
})); }));
} }
} catch (err) { } catch (err) {
console.error('Failed to parse chat message:', err); console.error('Failed to parse message:', err);
} }
}); };
// Send acknowledgment pc.onconnectionstatechange = () => {
channel.send(JSON.stringify({ console.log('Host connection state:', pc.connectionState);
type: 'identify_ack', };
from: myUsername
}));
}
} catch (err) {
console.error('Failed to parse identify message:', err);
}
});
channel.on('close', () => {
console.log(`👋 Chat closed: ${connectionId}`);
setActiveChats(prev => {
const updated = { ...prev };
Object.keys(updated).forEach(user => {
if (updated[user].connectionId === connectionId) {
updated[user] = { ...updated[user], status: 'disconnected' };
}
});
return updated;
});
});
}
});
// Start the service
await service.start();
// Listen for service events
service.on('connection', (connId) => {
console.log(`🔗 New connection: ${connId}`);
});
service.on('disconnection', (connId) => {
console.log(`🔌 Disconnected: ${connId}`);
});
service.on('ttl-refreshed', (expiresAt) => {
console.log(`🔄 Service TTL refreshed, expires at: ${new Date(expiresAt)}`);
});
service.on('error', (error, context) => {
console.error(`❌ Service error (${context}):`, error);
});
setServiceHandle(service);
console.log('✅ Chat service started');
} catch (err) {
console.error('Error starting chat service:', err);
toast.error(`Failed to start chat: ${err.message}`);
}
}; };
// Add contact // Add contact
@@ -398,7 +434,7 @@ export default function App() {
const newContacts = [...contacts, contactInput]; const newContacts = [...contacts, contactInput];
setContacts(newContacts); setContacts(newContacts);
localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts)); localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts));
setContactInput(''); setContactInput('');
toast.success(`Added ${contactInput}`); toast.success(`Added ${contactInput}`);
}; };
@@ -407,16 +443,16 @@ export default function App() {
const handleRemoveContact = (contact) => { const handleRemoveContact = (contact) => {
const newContacts = contacts.filter(c => c !== contact); const newContacts = contacts.filter(c => c !== contact);
setContacts(newContacts); setContacts(newContacts);
localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts)); localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts));
if (selectedChat === contact) { if (selectedChat === contact) {
setSelectedChat(null); setSelectedChat(null);
} }
toast.success(`Removed ${contact}`); toast.success(`Removed ${contact}`);
}; };
// Start chat with contact using durable connection // Start chat with contact (answerer role)
const handleStartChat = async (contact) => { const handleStartChat = async (contact) => {
if (!client || activeChats[contact]?.status === 'connected') { if (!rondevu || activeChats[contact]?.status === 'connected') {
setSelectedChat(contact); setSelectedChat(contact);
return; return;
} }
@@ -424,76 +460,161 @@ export default function App() {
try { try {
toast.loading(`Connecting to ${contact}...`, { id: 'connecting' }); toast.loading(`Connecting to ${contact}...`, { id: 'connecting' });
// Create durable connection // Discover peer's service
const connection = await client.connect(contact, 'chat.rondevu@1.0.0', { const fqn = `${CHAT_SERVICE}@${contact}`;
rtcConfig: getCurrentRtcConfig(), const serviceData = await rondevu.getService(fqn);
maxReconnectAttempts: 5
console.log('Found peer service:', serviceData);
// Create peer connection
const pc = new RTCPeerConnection(getCurrentRtcConfig());
// Handle incoming data channel
let dataChannel = null;
pc.ondatachannel = (event) => {
console.log('Received data channel from', contact);
dataChannel = event.channel;
setupClientChannel(dataChannel, contact, pc);
};
// Set remote offer
await pc.setRemoteDescription({
type: 'offer',
sdp: serviceData.sdp,
}); });
// Create data channel (must match service pool's channel name) // Create answer
const channel = connection.createChannel('rondevu-service'); const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Listen for connection events // Send answer
connection.on('connected', () => { await rondevu.postOfferAnswer(fqn, serviceData.offerId, answer.sdp);
console.log(`✅ Connected to ${contact}`);
});
connection.on('reconnecting', (attempt, max, delay) => { // Poll for ICE candidates
console.log(`🔄 Reconnecting to ${contact} (${attempt}/${max}) in ${delay}ms`); const lastIceTimestamp = { current: 0 };
toast.loading(`Reconnecting to ${contact}...`, { id: 'reconnecting' }); const icePolling = setInterval(async () => {
}); try {
const result = await rondevu.getOfferIceCandidates(
fqn,
serviceData.offerId,
lastIceTimestamp.current
);
connection.on('disconnected', () => { for (const item of result.candidates) {
console.log(`🔌 Disconnected from ${contact}`); if (item.candidate && item.candidate.candidate) {
setActiveChats(prev => ({ try {
...prev, const rtcCandidate = new RTCIceCandidate(item.candidate);
[contact]: { ...prev[contact], status: 'reconnecting' } await pc.addIceCandidate(rtcCandidate);
})); lastIceTimestamp.current = item.createdAt;
}); } catch (err) {
console.warn('Failed to process ICE candidate:', err);
lastIceTimestamp.current = item.createdAt;
}
} else {
lastIceTimestamp.current = item.createdAt;
}
}
} catch (err) {
if (err.message?.includes('404') || err.message?.includes('410')) {
console.warn('Offer expired, stopping ICE polling');
clearInterval(icePolling);
}
}
}, 1000);
connection.on('failed', (error) => { // Send local ICE candidates
console.error(`❌ Connection to ${contact} failed:`, error); pc.onicecandidate = (event) => {
toast.error(`Connection to ${contact} failed`, { id: 'connecting' }); if (event.candidate) {
rondevu.addOfferIceCandidates(
fqn,
serviceData.offerId,
[event.candidate.toJSON()]
).catch(err => console.error('Failed to send ICE candidate:', err));
}
};
pc.onconnectionstatechange = () => {
console.log('Client connection state:', pc.connectionState);
if (pc.connectionState === 'connected') {
toast.success(`Connected to ${contact}`, { id: 'connecting' });
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
toast.error(`Disconnected from ${contact}`);
clearInterval(icePolling);
setActiveChats(prev => ({ setActiveChats(prev => ({
...prev, ...prev,
[contact]: { ...prev[contact], status: 'disconnected' } [contact]: { ...prev[contact], status: 'disconnected' }
})); }));
}); }
};
// Wait for acknowledgment
channel.on('message', (data) => {
try {
const msg = JSON.parse(data);
if (msg.type === 'identify_ack') {
// Connection established
toast.success(`Connected to ${contact}`, { id: 'connecting' });
// Store connection info
setActiveChats(prev => ({ setActiveChats(prev => ({
...prev, ...prev,
[contact]: { [contact]: {
username: contact, username: contact,
channel, connection: pc,
connection, channel: dataChannel, // Will be set when ondatachannel fires
messages: prev[contact]?.messages || [], messages: prev[contact]?.messages || [],
status: 'connecting',
role: 'answerer',
serviceFqn: fqn,
offerId: serviceData.offerId,
icePolling
}
}));
setSelectedChat(contact);
} catch (err) {
console.error('Failed to connect:', err);
toast.error(`Failed to connect to ${contact}`, { id: 'connecting' });
}
};
// Setup client data channel
const setupClientChannel = (dc, contact, pc) => {
dc.onopen = () => {
console.log('Client data channel opened with', contact);
// Send identification
dc.send(JSON.stringify({
type: 'identify',
from: myUsername
}));
};
dc.onclose = () => {
console.log('Client data channel closed');
setActiveChats(prev => ({
...prev,
[contact]: { ...prev[contact], status: 'disconnected' }
}));
};
dc.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'identify_ack') {
// Connection acknowledged
setActiveChats(prev => ({
...prev,
[contact]: {
...prev[contact],
channel: dc,
status: 'connected' status: 'connected'
} }
})); }));
setSelectedChat(contact); toast.success(`Connected to ${contact}`, { id: 'connecting' });
} else if (msg.type === 'message') {
// Update handler for chat messages // Chat message
channel.off('message');
channel.on('message', (chatData) => {
try {
const chatMsg = JSON.parse(chatData);
if (chatMsg.type === 'message') {
setActiveChats(prev => ({ setActiveChats(prev => ({
...prev, ...prev,
[contact]: { [contact]: {
...prev[contact], ...prev[contact],
messages: [...(prev[contact]?.messages || []), { messages: [...(prev[contact]?.messages || []), {
from: contact, from: contact,
text: chatMsg.text, text: msg.text,
timestamp: Date.now() timestamp: Date.now()
}] }]
} }
@@ -502,33 +623,16 @@ export default function App() {
} catch (err) { } catch (err) {
console.error('Failed to parse message:', err); console.error('Failed to parse message:', err);
} }
}); };
}
} catch (err) {
console.error('Failed to parse ack:', err);
}
});
channel.on('close', () => { // Update the channel reference in state
setActiveChats(prev => ({ setActiveChats(prev => ({
...prev, ...prev,
[contact]: { ...prev[contact], status: 'disconnected' } [contact]: {
})); ...prev[contact],
toast.error(`Disconnected from ${contact}`); channel: dc
});
// Connect and send identification
await connection.connect();
channel.send(JSON.stringify({
type: 'identify',
from: myUsername
}));
} catch (err) {
console.error('Failed to connect:', err);
toast.error(`Failed to connect to ${contact}`, { id: 'connecting' });
} }
}));
}; };
// Send message // Send message
@@ -575,7 +679,7 @@ export default function App() {
} }
}; };
if (!client) { if (!rondevu) {
return <div style={styles.loading}>Loading...</div>; return <div style={styles.loading}>Loading...</div>;
} }
@@ -651,9 +755,9 @@ export default function App() {
<h1 style={styles.setupTitle}>Rondevu Chat</h1> <h1 style={styles.setupTitle}>Rondevu Chat</h1>
<p style={styles.setupSubtitle}>Decentralized P2P Chat</p> <p style={styles.setupSubtitle}>Decentralized P2P Chat</p>
{setupStep === 'register' && ( {setupStep === 'init' && (
<div> <div>
<p style={styles.setupDesc}>Registering...</p> <p style={styles.setupDesc}>Initializing...</p>
</div> </div>
)} )}