import React, { useState, useEffect, useRef } from 'react' import { RondevuService, RondevuSignaler, WebRTCContext, RTCDurableConnection, ServiceHost, ServiceClient } from '@xtr-dev/rondevu-client' import toast, { Toaster } from 'react-hot-toast' const API_URL = 'https://api.ronde.vu' const CHAT_SERVICE = 'chat.rondevu@2.0.0' // RTC Presets remain the same 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', }, ], }, }, } export default function App() { // Core state const [rondevuService, setRondevuService] = useState(null) const [serviceHost, setServiceHost] = useState(null) const [myUsername, setMyUsername] = useState(null) const [setupStep, setSetupStep] = useState('init') // init, claim, ready const [usernameInput, setUsernameInput] = useState('') // Chat state const [contacts, setContacts] = useState([]) const [contactInput, setContactInput] = useState('') const [activeChats, setActiveChats] = useState({}) // { username: { client, connection, messages } } const [selectedChat, setSelectedChat] = useState(null) const [messageInputs, setMessageInputs] = useState({}) const [rtcPreset] = useState('ipv4-turn') const chatEndRef = useRef(null) // Initialize Rondevu Service useEffect(() => { const init = async () => { try { // Load saved data const savedUsername = localStorage.getItem('rondevu-v2-username') const savedKeypair = localStorage.getItem('rondevu-v2-keypair') const savedContacts = localStorage.getItem('rondevu-v2-contacts') if (savedContacts) { try { setContacts(JSON.parse(savedContacts)) } catch (err) { console.error('Failed to load contacts:', err) } } // Create service const service = new RondevuService({ apiUrl: API_URL, username: savedUsername || 'temp', keypair: savedKeypair ? JSON.parse(savedKeypair) : undefined, }) await service.initialize() setRondevuService(service) // Check if we have a saved username and it's still valid if (savedUsername && savedKeypair) { try { // Verify the username is still claimed by checking with the server const isClaimed = await service.isUsernameClaimed() if (isClaimed) { setMyUsername(savedUsername) setSetupStep('ready') console.log('Restored session for username:', savedUsername) toast.success(`Welcome back, ${savedUsername}!`, { duration: 3000 }) } else { // Username expired or was never properly claimed console.log('Saved username is no longer valid, need to reclaim') setSetupStep('claim') } } catch (err) { console.error('Failed to verify username claim:', err) // Keep the saved data but require reclaim setSetupStep('claim') } } else { setSetupStep('claim') } } catch (err) { console.error('Initialization failed:', err) toast.error(`Failed to initialize: ${err.message}`) setSetupStep('claim') } } init() }, []) // Start hosting service when ready useEffect(() => { if (setupStep === 'ready' && myUsername && rondevuService && !serviceHost) { startHosting() } }, [setupStep, myUsername, rondevuService]) // Auto-scroll chat useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [activeChats, selectedChat]) // Claim username const handleClaimUsername = async () => { if (!rondevuService || !usernameInput) return try { await rondevuService.claimUsername() // Save username and keypair setMyUsername(usernameInput) localStorage.setItem('rondevu-v2-username', usernameInput) localStorage.setItem( 'rondevu-v2-keypair', JSON.stringify(rondevuService.getKeypair()) ) setSetupStep('ready') toast.success(`Welcome, ${usernameInput}!`) } catch (err) { toast.error(`Error: ${err.message}`) } } // Start hosting chat service const startHosting = async () => { if (!rondevuService || serviceHost) return try { const host = new ServiceHost({ service: CHAT_SERVICE, rondevuService, maxPeers: 5, ttl: 300000, isPublic: true, rtcConfiguration: RTC_PRESETS[rtcPreset].config, }) // Listen for incoming connections host.events.on('connection', conn => { console.log(`New incoming connection: ${conn.id}`) // Wait for peer to identify let peerUsername = null const messageHandler = msg => { try { const data = JSON.parse(msg) if (data.type === 'identify') { peerUsername = data.from // Update active chats setActiveChats(prev => ({ ...prev, [peerUsername]: { connection: conn, messages: prev[peerUsername]?.messages || [], status: 'connected', }, })) // Send acknowledgment conn.sendMessage( JSON.stringify({ type: 'identify_ack', from: myUsername }) ) // Remove identify handler, add message handler conn.events.off('message', messageHandler) conn.events.on('message', chatMsg => { try { const chatData = JSON.parse(chatMsg) if (chatData.type === 'message') { setActiveChats(prev => ({ ...prev, [peerUsername]: { ...prev[peerUsername], messages: [ ...(prev[peerUsername]?.messages || []), { from: peerUsername, text: chatData.text, timestamp: Date.now(), }, ], }, })) } } catch (err) { console.error('Failed to parse chat message:', err) } }) } } catch (err) { console.error('Failed to parse identify message:', err) } } conn.events.on('message', messageHandler) conn.events.on('state-change', state => { if (state === 'disconnected' && peerUsername) { setActiveChats(prev => ({ ...prev, [peerUsername]: { ...prev[peerUsername], status: 'disconnected' }, })) } }) }) host.events.on('error', error => { console.error('Host error:', error) toast.error(`Service error: ${error.message}`) }) await host.start() setServiceHost(host) console.log('✅ Chat service started') } catch (err) { console.error('Failed to start hosting:', err) toast.error(`Failed to start service: ${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-v2-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-v2-contacts', JSON.stringify(newContacts)) if (selectedChat === contact) { setSelectedChat(null) } toast.success(`Removed ${contact}`) } // Start chat with contact const handleStartChat = async contact => { if (activeChats[contact]?.status === 'connected') { setSelectedChat(contact) return } try { toast.loading(`Connecting to ${contact}...`, { id: 'connecting' }) const client = new ServiceClient({ username: contact, serviceFqn: CHAT_SERVICE, rondevuService, autoReconnect: true, rtcConfiguration: RTC_PRESETS[rtcPreset].config, }) // Listen for events client.events.on('connected', conn => { console.log(`✅ Connected to ${contact}`) toast.success(`Connected to ${contact}`, { id: 'connecting' }) setActiveChats(prev => ({ ...prev, [contact]: { client, connection: conn, messages: prev[contact]?.messages || [], status: 'connected', }, })) setSelectedChat(contact) // Handle messages conn.events.on('message', msg => { try { const data = JSON.parse(msg) if (data.type === 'message') { setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], messages: [ ...(prev[contact]?.messages || []), { from: contact, text: data.text, timestamp: Date.now(), }, ], }, })) } else if (data.type === 'identify_ack') { console.log(`Got identify_ack from ${contact}`) } } catch (err) { console.error('Failed to parse message:', err) } }) // Send identification conn.sendMessage(JSON.stringify({ type: 'identify', from: myUsername })) }) client.events.on('disconnected', () => { console.log(`🔌 Disconnected from ${contact}`) setActiveChats(prev => ({ ...prev, [contact]: { ...prev[contact], status: 'disconnected' }, })) }) client.events.on('reconnecting', ({ attempt, maxAttempts }) => { console.log(`🔄 Reconnecting to ${contact} (${attempt}/${maxAttempts})`) toast.loading(`Reconnecting to ${contact}...`, { id: 'reconnecting' }) }) client.events.on('error', error => { console.error(`❌ Connection error:`, error) toast.error(`Connection failed: ${error.message}`, { id: 'connecting' }) }) // Connect await client.connect() } 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]?.connection) return const chat = activeChats[contact] if (chat.status !== 'connected') { toast.error('Not connected') return } try { chat.connection.sendMessage(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') } } // Logout const handleLogout = () => { if (window.confirm('Are you sure you want to logout?')) { localStorage.clear() window.location.reload() } } if (!rondevuService) { return
v2.0 - Decentralized P2P Chat
{setupStep === 'init' && (Initializing...
Choose your unique username
setUsernameInput(e.target.value.toLowerCase())} onKeyPress={e => e.key === 'Enter' && handleClaimUsername()} style={styles.setupInput} autoFocus />No friends yet
No messages yet