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 RTC_CONFIG = { iceServers: [ { urls: ["stun:stun.ronde.vu:3478"] }, { urls: [ "turn:turn.ronde.vu:3478?transport=tcp", "turn:turn.ronde.vu:3478?transport=udp", ], username: "webrtcuser", credential: "supersecretpassword" } ], // Force TURN relay to bypass NAT hairpinning (when testing on same network) // Comment out for production to allow direct connections when possible iceTransportPolicy: 'relay' }; export default function App() { const [client, setClient] = useState(null); const [credentials, setCredentials] = useState(null); const [myUsername, setMyUsername] = useState(null); // Setup const [setupStep, setSetupStep] = useState('register'); // register, claim, ready const [usernameInput, setUsernameInput] = useState(''); // Contacts const [contacts, setContacts] = useState([]); const [contactInput, setContactInput] = useState(''); const [onlineUsers, setOnlineUsers] = useState(new Set()); // Chat const [activeChats, setActiveChats] = useState({}); const [selectedChat, setSelectedChat] = useState(null); const [messageInputs, setMessageInputs] = useState({}); // Service const [serviceHandle, setServiceHandle] = useState(null); const chatEndRef = useRef(null); // Load saved data and auto-register useEffect(() => { const savedCreds = localStorage.getItem('rondevu-chat-credentials'); const savedUsername = localStorage.getItem('rondevu-chat-username'); const savedContacts = localStorage.getItem('rondevu-chat-contacts'); const initialize = async () => { let clientInstance; // Load contacts first if (savedContacts) { try { setContacts(JSON.parse(savedContacts)); } catch (err) { console.error('Failed to load contacts:', err); } } // Handle credentials 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 if (savedUsername) { setMyUsername(savedUsername); setSetupStep('ready'); } else { // Have creds but no username - go to claim step setSetupStep('claim'); } } catch (err) { console.error('Failed to load credentials:', err); // Invalid saved creds - auto-register 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'); } catch (err) { console.error('Auto-registration failed:', err); toast.error(`Registration failed: ${err.message}`); setSetupStep('claim'); // Still allow username claim, might work anyway } }; initialize(); }, []); // Auto-scroll chat useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [activeChats, selectedChat]); // Start chat service when ready useEffect(() => { if (setupStep === 'ready' && myUsername && client && !serviceHandle) { startChatService(); } }, [setupStep, myUsername, client]); // 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(() => { if (setupStep !== 'ready' || !client) return; const checkOnlineStatus = async () => { const online = new Set(); for (const contact of contacts) { try { // Try to query the service via discovery endpoint const response = await fetch(`${API_URL}/index/${contact}/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serviceFqn: 'chat.rondevu@1.0.0' }) }); if (response.ok) { online.add(contact); } } catch (err) { // User offline or doesn't exist } } setOnlineUsers(online); }; checkOnlineStatus(); const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s return () => clearInterval(interval); }, [contacts, setupStep, client]); // Claim username const handleClaimUsername = async () => { if (!client || !usernameInput) return; try { const claim = await client.usernames.claimUsername(usernameInput); client.usernames.saveKeypairToStorage(usernameInput, claim.publicKey, claim.privateKey); setMyUsername(usernameInput); localStorage.setItem('rondevu-chat-username', usernameInput); setSetupStep('ready'); toast.success(`Welcome, ${usernameInput}!`); } catch (err) { toast.error(`Error: ${err.message}`); } }; // Start pooled chat service with durable connections const startChatService = async () => { if (!client || !myUsername || serviceHandle) return; try { const keypair = client.usernames.loadKeypairFromStorage(myUsername); if (!keypair) { toast.error('Username keypair not found'); return; } const service = await client.exposeService({ username: myUsername, privateKey: keypair.privateKey, 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: RTC_CONFIG, handler: (channel, connectionId) => { console.log(`📡 New chat connection: ${connectionId}`); // Wait for peer to identify themselves channel.on('message', (data) => { try { const msg = JSON.parse(data); if (msg.type === 'identify') { // Peer identified themselves setActiveChats(prev => ({ ...prev, [msg.from]: { username: msg.from, channel, connectionId, messages: prev[msg.from]?.messages || [], status: 'connected' } })); // Remove old handler and add new one for chat messages channel.off('message'); channel.on('message', (chatData) => { try { const chatMsg = JSON.parse(chatData); if (chatMsg.type === 'message') { setActiveChats(prev => ({ ...prev, [msg.from]: { ...prev[msg.from], messages: [...(prev[msg.from]?.messages || []), { from: msg.from, text: chatMsg.text, timestamp: Date.now() }] } })); } } catch (err) { console.error('Failed to parse chat message:', err); } }); // Send acknowledgment channel.send(JSON.stringify({ 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 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-chat-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-chat-contacts', JSON.stringify(newContacts)); if (selectedChat === contact) { setSelectedChat(null); } toast.success(`Removed ${contact}`); }; // Start chat with contact using durable connection const handleStartChat = async (contact) => { if (!client || activeChats[contact]?.status === 'connected') { setSelectedChat(contact); return; } try { toast.loading(`Connecting to ${contact}...`, { id: 'connecting' }); // Create durable connection const connection = await client.connect(contact, 'chat.rondevu@1.0.0', { rtcConfig: RTC_CONFIG, maxReconnectAttempts: 5 }); // Create data channel const channel = connection.createChannel('chat'); // Listen for connection events connection.on('connected', () => { console.log(`✅ Connected to ${contact}`); }); connection.on('reconnecting', (attempt, max, delay) => { console.log(`🔄 Reconnecting to ${contact} (${attempt}/${max}) in ${delay}ms`); toast.loading(`Reconnecting to ${contact}...`, { id: 'reconnecting' }); }); connection.on('disconnected', () => { console.log(`🔌 Disconnected from ${contact}`); setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], status: 'reconnecting' } })); }); connection.on('failed', (error) => { console.error(`❌ Connection to ${contact} failed:`, error); toast.error(`Connection to ${contact} failed`, { id: 'connecting' }); setActiveChats(prev => ({ ...prev, [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' }); setActiveChats(prev => ({ ...prev, [contact]: { username: contact, channel, connection, messages: prev[contact]?.messages || [], status: 'connected' } })); setSelectedChat(contact); // Update handler for chat messages channel.off('message'); channel.on('message', (chatData) => { try { const chatMsg = JSON.parse(chatData); if (chatMsg.type === 'message') { setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], messages: [...(prev[contact]?.messages || []), { from: contact, text: chatMsg.text, timestamp: Date.now() }] } })); } } catch (err) { console.error('Failed to parse message:', err); } }); } } catch (err) { console.error('Failed to parse ack:', err); } }); channel.on('close', () => { setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], status: 'disconnected' } })); toast.error(`Disconnected from ${contact}`); }); // 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 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 (!client) { return
Decentralized P2P Chat
{setupStep === 'register' && (Registering...
Choose your unique username
setUsernameInput(e.target.value.toLowerCase())} onKeyPress={(e) => e.key === 'Enter' && handleClaimUsername()} style={styles.setupInput} autoFocus />3-32 characters, lowercase letters, numbers, and dashes only
No friends yet
Add friends by their username above
Click on a friend from the sidebar to start chatting
No messages yet
Send a message to start the conversation