import React, { useState, useEffect, useRef } from 'react'; import { Rondevu } from '@xtr-dev/rondevu-client'; import toast, { Toaster } from 'react-hot-toast'; const API_URL = 'https://api.ronde.vu'; const CHAT_SERVICE = 'chat:2.0.0'; // Preset RTC configurations const RTC_PRESETS = { 'ipv4-turn': { name: 'IPv4 TURN (Recommended)', config: { iceServers: [ { urls: ["stun:57.129.61.67:3478"] }, { urls: [ "turn:57.129.61.67:3478?transport=tcp", "turn:57.129.61.67:3478?transport=udp", ], username: "webrtcuser", credential: "supersecretpassword" } ], } }, 'hostname-turns': { name: 'Hostname TURNS (TLS)', config: { iceServers: [ { urls: ["stun:turn.share.fish:3478"] }, { urls: [ "turns:turn.share.fish:5349?transport=tcp", "turns:turn.share.fish:5349?transport=udp", "turn:turn.share.fish:3478?transport=tcp", "turn:turn.share.fish:3478?transport=udp", ], username: "webrtcuser", credential: "supersecretpassword" } ], } }, 'google-stun': { name: 'Google STUN Only', config: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] } }, 'relay-only': { name: 'Force TURN Relay (Testing)', config: { iceServers: [ { urls: ["stun:57.129.61.67:3478"] }, { urls: [ "turn:57.129.61.67:3478?transport=tcp", "turn:57.129.61.67:3478?transport=udp", ], username: "webrtcuser", credential: "supersecretpassword" } ], iceTransportPolicy: 'relay' } }, 'custom': { name: 'Custom Configuration', config: null // Will be loaded from user input } }; export default function App() { const [rondevu, setRondevu] = useState(null); const [myUsername, setMyUsername] = useState(null); // Setup const [setupStep, setSetupStep] = useState('init'); // init, claim, ready const [usernameInput, setUsernameInput] = useState(''); // Contacts const [contacts, setContacts] = useState([]); const [contactInput, setContactInput] = useState(''); const [onlineUsers, setOnlineUsers] = useState(new Set()); // Chat - structure: { [username]: { connection, channel, messages, status, role, serviceFqn, offerId, polling } } const [activeChats, setActiveChats] = useState({}); const [selectedChat, setSelectedChat] = useState(null); const [messageInputs, setMessageInputs] = useState({}); // Service - we publish one service that can accept multiple connections const [myServicePublished, setMyServicePublished] = useState(false); const [hostConnections, setHostConnections] = useState({}); // Track incoming connections as host const [offerIdToPeerConnection, setOfferIdToPeerConnection] = useState({}); // Map offerId to RTCPeerConnection const [lastAnswerTimestamp, setLastAnswerTimestamp] = useState(0); // Track last answer timestamp for polling const chatEndRef = useRef(null); // Settings const [showSettings, setShowSettings] = useState(false); const [rtcPreset, setRtcPreset] = useState('ipv4-turn'); const [customRtcConfig, setCustomRtcConfig] = useState(''); // Get current RTC configuration const getCurrentRtcConfig = () => { if (rtcPreset === 'custom') { try { return JSON.parse(customRtcConfig); } catch (err) { console.error('Invalid custom RTC config:', err); return RTC_PRESETS['ipv4-turn'].config; } } return RTC_PRESETS[rtcPreset]?.config || RTC_PRESETS['ipv4-turn'].config; }; // Load saved settings useEffect(() => { const savedPreset = localStorage.getItem('rondevu-rtc-preset'); const savedCustomConfig = localStorage.getItem('rondevu-rtc-custom'); if (savedPreset) { setRtcPreset(savedPreset); } if (savedCustomConfig) { setCustomRtcConfig(savedCustomConfig); } }, []); // Save settings when they change useEffect(() => { localStorage.setItem('rondevu-rtc-preset', rtcPreset); }, [rtcPreset]); useEffect(() => { if (customRtcConfig) { localStorage.setItem('rondevu-rtc-custom', customRtcConfig); } }, [customRtcConfig]); // Initialize Rondevu useEffect(() => { const init = async () => { try { const savedUsername = localStorage.getItem('rondevu-username'); const savedKeypair = localStorage.getItem('rondevu-keypair'); const savedContacts = localStorage.getItem('rondevu-contacts'); console.log('[Init] Saved username:', savedUsername); console.log('[Init] Has saved keypair:', !!savedKeypair); // Load contacts if (savedContacts) { try { setContacts(JSON.parse(savedContacts)); } catch (err) { console.error('Failed to load contacts:', err); } } const parsedKeypair = savedKeypair ? JSON.parse(savedKeypair) : undefined; const service = new Rondevu({ 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); setSetupStep('ready'); toast.success(`Welcome back, ${savedUsername}!`); } else { console.warn('[Init] Username not claimed on server, need to claim'); setSetupStep('claim'); } } else { setSetupStep('claim'); } } catch (err) { console.error('Initialization failed:', err); toast.error(`Failed to initialize: ${err.message}`); setSetupStep('claim'); } }; init(); }, []); // Auto-scroll chat useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [activeChats, selectedChat]); // Publish service when ready useEffect(() => { if (setupStep === 'ready' && myUsername && rondevu && !myServicePublished) { publishMyService(); } }, [setupStep, myUsername, rondevu, myServicePublished]); // Poll for answered offers (host side) useEffect(() => { if (!myServicePublished || !rondevu || Object.keys(offerIdToPeerConnection).length === 0) { return; } console.log('[Answer Polling] Starting to poll for answers...'); const pollForAnswers = async () => { try { const result = await rondevu.getAnsweredOffers(lastAnswerTimestamp); if (result.offers.length > 0) { console.log(`[Answer Polling] Found ${result.offers.length} new answered offer(s)`); for (const answer of result.offers) { const pc = offerIdToPeerConnection[answer.offerId]; if (pc && pc.signalingState !== 'stable') { console.log(`[Answer Polling] Setting remote answer for offer ${answer.offerId}`); await pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); // Update last answer timestamp setLastAnswerTimestamp(prev => Math.max(prev, answer.answeredAt)); console.log(`✅ [Answer Polling] Remote answer set for offer ${answer.offerId}`); } else if (pc) { console.log(`[Answer Polling] Skipping offer ${answer.offerId} - already in stable state`); } } } } catch (err) { console.error('[Answer Polling] Error polling for answers:', err); } }; // Poll every 2 seconds const interval = setInterval(pollForAnswers, 2000); pollForAnswers(); // Initial poll return () => clearInterval(interval); }, [myServicePublished, rondevu, offerIdToPeerConnection, lastAnswerTimestamp]); // Check online status periodically useEffect(() => { if (setupStep !== 'ready' || !rondevu) return; const checkOnlineStatus = async () => { const online = new Set(); for (const contact of contacts) { try { const fqn = `${CHAT_SERVICE}@${contact}`; await rondevu.getService(fqn); online.add(contact); } catch (err) { // User offline or doesn't have service published } } setOnlineUsers(online); }; checkOnlineStatus(); const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s return () => clearInterval(interval); }, [contacts, setupStep, rondevu]); // Claim username const handleClaimUsername = async () => { if (!rondevu || !usernameInput) return; try { const keypair = rondevu.getKeypair(); const newService = new Rondevu({ apiUrl: API_URL, username: usernameInput, keypair, }); await newService.initialize(); await newService.claimUsername(); setRondevu(newService); setMyUsername(usernameInput); localStorage.setItem('rondevu-username', usernameInput); localStorage.setItem('rondevu-keypair', JSON.stringify(keypair)); setSetupStep('ready'); toast.success(`Welcome, ${usernameInput}!`); } catch (err) { toast.error(`Error: ${err.message}`); } }; // Publish service to accept incoming connections const publishMyService = async () => { try { // Verify username is claimed with correct keypair before publishing console.log('[Publish] Verifying username claim...'); 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; } } catch (e) { console.error('[Publish] Failed to check username:', e); } // Try to claim username console.log('[Publish] Attempting to claim username...'); toast.loading('Claiming username...', { id: 'claim' }); try { 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 const connections = []; // Track connections before publishing 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 }); connections.push({ pc, dc, index: i }); } // Publish service const fqn = `${CHAT_SERVICE}@${myUsername}`; console.log('[Publish] Publishing service with FQN:', fqn); console.log('[Publish] Public key:', rondevu.getPublicKey()); const publishResult = await rondevu.publishService({ serviceFqn: fqn, offers, ttl: 300000, // 5 minutes }); // Map offerIds to peer connections const offerMapping = {}; const hostConnMap = {}; publishResult.offers.forEach((offer, idx) => { const conn = connections[idx]; offerMapping[offer.offerId] = conn.pc; hostConnMap[`host-${idx}`] = { pc: conn.pc, dc: conn.dc, offerId: offer.offerId, status: 'waiting' }; }); setOfferIdToPeerConnection(offerMapping); setHostConnections(hostConnMap); setMyServicePublished(true); console.log('✅ Chat service published successfully with', poolSize, 'offers'); console.log('[Publish] Offer IDs:', Object.keys(offerMapping)); 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') { // Peer identified themselves peerUsername = msg.from; console.log(`📡 New chat connection from: ${peerUsername}`); setActiveChats(prev => ({ ...prev, [peerUsername]: { username: peerUsername, channel: dc, connection: pc, messages: prev[peerUsername]?.messages || [], status: 'connected', role: 'host' } })); // Send acknowledgment dc.send(JSON.stringify({ type: 'identify_ack', from: hostUsername })); } else if (msg.type === 'message' && peerUsername) { // Chat message setActiveChats(prev => ({ ...prev, [peerUsername]: { ...prev[peerUsername], messages: [...(prev[peerUsername]?.messages || []), { from: peerUsername, text: msg.text, timestamp: Date.now() }] } })); } } catch (err) { console.error('Failed to parse message:', err); } }; pc.onconnectionstatechange = () => { console.log('Host connection state:', pc.connectionState); }; }; // Add contact const handleAddContact = () => { if (!contactInput || contacts.includes(contactInput)) { toast.error('Invalid or duplicate contact'); return; } if (contactInput === myUsername) { toast.error("You can't add yourself!"); return; } const newContacts = [...contacts, contactInput]; setContacts(newContacts); localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts)); setContactInput(''); toast.success(`Added ${contactInput}`); }; // Remove contact const handleRemoveContact = (contact) => { const newContacts = contacts.filter(c => c !== contact); setContacts(newContacts); localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts)); if (selectedChat === contact) { setSelectedChat(null); } toast.success(`Removed ${contact}`); }; // Start chat with contact (answerer role) const handleStartChat = async (contact) => { if (!rondevu || activeChats[contact]?.status === 'connected') { setSelectedChat(contact); return; } try { toast.loading(`Connecting to ${contact}...`, { id: 'connecting' }); // Discover peer's service const fqn = `${CHAT_SERVICE}@${contact}`; const serviceData = await rondevu.getService(fqn); 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 answer const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); // Send answer await rondevu.postOfferAnswer(fqn, serviceData.offerId, answer.sdp); // Poll for ICE candidates const lastIceTimestamp = { current: 0 }; const icePolling = setInterval(async () => { try { const result = await rondevu.getOfferIceCandidates( fqn, serviceData.offerId, lastIceTimestamp.current ); for (const item of result.candidates) { if (item.candidate && item.candidate.candidate) { try { const rtcCandidate = new RTCIceCandidate(item.candidate); 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); // Send local ICE candidates pc.onicecandidate = (event) => { 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 => ({ ...prev, [contact]: { ...prev[contact], status: 'disconnected' } })); } }; // Store connection info setActiveChats(prev => ({ ...prev, [contact]: { username: contact, connection: pc, channel: dataChannel, // Will be set when ondatachannel fires 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' } })); toast.success(`Connected to ${contact}`, { id: 'connecting' }); } else if (msg.type === 'message') { // Chat message setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], messages: [...(prev[contact]?.messages || []), { from: contact, text: msg.text, timestamp: Date.now() }] } })); } } catch (err) { console.error('Failed to parse message:', err); } }; // Update the channel reference in state setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], channel: dc } })); }; // Send message const handleSendMessage = (contact) => { const text = messageInputs[contact]; if (!text || !activeChats[contact]?.channel) return; const chat = activeChats[contact]; if (chat.status !== 'connected') { toast.error('Not connected'); return; } try { chat.channel.send(JSON.stringify({ type: 'message', text })); setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], messages: [...prev[contact].messages, { from: myUsername, text, timestamp: Date.now() }] } })); setMessageInputs(prev => ({ ...prev, [contact]: '' })); } catch (err) { console.error('Failed to send message:', err); toast.error('Failed to send message'); } }; // Clear all data const handleLogout = () => { if (window.confirm('Are you sure you want to logout? This will clear all data.')) { localStorage.clear(); window.location.reload(); } }; if (!rondevu) { return
Loading...
; } return (
{/* Settings Modal */} {showSettings && (
setShowSettings(false)}>
e.stopPropagation()}>

WebRTC Configuration

{rtcPreset === 'custom' && (