diff --git a/src/App.jsx b/src/App.jsx index 9b2f946..9a67390 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,541 +1,1413 @@ -import React, { useState, useEffect, useRef } from 'react' -import { Rondevu } from '@xtr-dev/rondevu-client' -import toast, { Toaster } from 'react-hot-toast' +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' +const API_URL = 'https://api.ronde.vu'; +const CHAT_SERVICE = 'chat:2.0.0'; -const RTC_CONFIG = { - iceServers: [ - { urls: ['stun:57.129.61.67:3478'] }, +// 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', - }, - ], -} + 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() { - // Setup state - const [rondevu, setRondevu] = useState(null) - const [myUsername, setMyUsername] = useState(null) - const [setupStep, setSetupStep] = useState('init') // init, claim, ready - const [usernameInput, setUsernameInput] = useState('') + const [rondevu, setRondevu] = useState(null); + const [myUsername, setMyUsername] = useState(null); - // Chat state - const [peerUsername, setPeerUsername] = useState('') - const [peerConnection, setPeerConnection] = useState(null) - const [dataChannel, setDataChannel] = useState(null) - const [connectionState, setConnectionState] = useState('disconnected') // disconnected, connecting, connected - const [messages, setMessages] = useState([]) - const [messageInput, setMessageInput] = useState('') - const [role, setRole] = useState(null) // 'offerer' or 'answerer' + // Setup + const [setupStep, setSetupStep] = useState('init'); // init, claim, ready + const [usernameInput, setUsernameInput] = useState(''); - // Signaling state - const [serviceFqn, setServiceFqn] = useState(null) - const [offerId, setOfferId] = useState(null) - const [answerPolling, setAnswerPolling] = useState(null) - const [icePolling, setIcePolling] = useState(null) - const lastIceTimestamp = useRef(0) + // Contacts + const [contacts, setContacts] = useState([]); + const [contactInput, setContactInput] = useState(''); + const [onlineUsers, setOnlineUsers] = useState(new Set()); - const messagesEndRef = useRef(null) + // Chat - structure: { [username]: { connection, channel, messages, status, role, serviceFqn, offerId, polling } } + const [activeChats, setActiveChats] = useState({}); + const [selectedChat, setSelectedChat] = useState(null); + const [messageInputs, setMessageInputs] = useState({}); - // Initialize Rondevu Service - useEffect(() => { - const init = async () => { - try { - const savedUsername = localStorage.getItem('rondevu-username') - const savedKeypair = localStorage.getItem('rondevu-keypair') + // 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 parsedKeypair = savedKeypair ? JSON.parse(savedKeypair) : undefined + const chatEndRef = useRef(null); - const service = new Rondevu({ - apiUrl: API_URL, - username: savedUsername || 'temp', - keypair: parsedKeypair, - }) + // Settings + const [showSettings, setShowSettings] = useState(false); + const [rtcPreset, setRtcPreset] = useState('ipv4-turn'); + const [customRtcConfig, setCustomRtcConfig] = useState(''); - await service.initialize() - setRondevu(service) + // 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; + }; - if (savedUsername && savedKeypair) { - const isClaimed = await service.isUsernameClaimed() - if (isClaimed) { - setMyUsername(savedUsername) - setSetupStep('ready') - toast.success(`Welcome back, ${savedUsername}!`) + // Load saved settings + useEffect(() => { + const savedPreset = localStorage.getItem('rondevu-rtc-preset'); + const savedCustomConfig = localStorage.getItem('rondevu-rtc-custom'); - // Publish service - await publishService(service, savedUsername) - } else { - setSetupStep('claim') - } - } else { - setSetupStep('claim') - } - } catch (err) { - console.error('Initialization failed:', err) - toast.error(`Failed to initialize: ${err.message}`) - setSetupStep('claim') - } + 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'); + + // Load contacts + if (savedContacts) { + try { + setContacts(JSON.parse(savedContacts)); + } catch (err) { + console.error('Failed to load contacts:', err); + } } - init() - }, []) + const parsedKeypair = savedKeypair ? JSON.parse(savedKeypair) : undefined; - // Auto-scroll messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + const service = new Rondevu({ + apiUrl: API_URL, + username: savedUsername || 'temp', + keypair: parsedKeypair, + }); - // Cleanup on unmount - useEffect(() => { - return () => { - if (answerPolling) clearInterval(answerPolling) - if (icePolling) clearInterval(icePolling) + await service.initialize(); + setRondevu(service); + + if (savedUsername && savedKeypair) { + const isClaimed = await service.isUsernameClaimed(); + if (isClaimed) { + setMyUsername(savedUsername); + setSetupStep('ready'); + toast.success(`Welcome back, ${savedUsername}!`); + } else { + setSetupStep('claim'); + } + } else { + setSetupStep('claim'); } - }, [answerPolling, icePolling]) + } catch (err) { + console.error('Initialization failed:', err); + toast.error(`Failed to initialize: ${err.message}`); + setSetupStep('claim'); + } + }; - // Publish chat service (offerer) - const publishService = async (service, username) => { + 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]); + + // Check online status periodically + useEffect(() => { + if (setupStep !== 'ready' || !rondevu) return; + + const checkOnlineStatus = async () => { + const online = new Set(); + for (const contact of contacts) { try { - // Create peer connection - const pc = new RTCPeerConnection(RTC_CONFIG) - const dc = pc.createDataChannel('chat') - - setupDataChannel(dc) - setupPeerConnection(pc) - - // Create offer - const offer = await pc.createOffer() - await pc.setLocalDescription(offer) - - // Publish service with FQN format: chat:2.0.0@username - const fqn = `${CHAT_SERVICE}@${username}` - const publishedService = await service.publishService({ - serviceFqn: fqn, - offers: [{ sdp: offer.sdp }], - ttl: 300000, - }) - - const firstOffer = publishedService.offers[0] - setServiceFqn(fqn) - setOfferId(firstOffer.offerId) - setPeerConnection(pc) - setDataChannel(dc) - setRole('offerer') - - // Poll for answer - startAnswerPolling(service, fqn, firstOffer.offerId, pc) - - // Poll for ICE candidates - startIcePolling(service, fqn, firstOffer.offerId, pc) - - // Send local ICE candidates - pc.onicecandidate = (event) => { - if (event.candidate) { - console.log('Sending ICE candidate') - service.addOfferIceCandidates( - fqn, - firstOffer.offerId, - [event.candidate.toJSON()] - ).catch(err => console.error('Failed to send ICE candidate:', err)) - } - } - - toast.success('Service published! Waiting for peer...') + const fqn = `${CHAT_SERVICE}@${contact}`; + await rondevu.getService(fqn); + online.add(contact); } catch (err) { - console.error('Failed to publish service:', err) - toast.error(`Failed to publish service: ${err.message}`) + // User offline or doesn't have service published } - } + } + setOnlineUsers(online); + }; - // Poll for answer from answerer (offerer side) - const startAnswerPolling = (service, fqn, offerId, pc) => { - const interval = setInterval(async () => { - try { - const answer = await service.getOfferAnswer(fqn, offerId) - if (answer && answer.sdp) { - console.log('Received answer') - clearInterval(interval) - setAnswerPolling(null) - await pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }) - toast.success('Peer connected!') - } - } catch (err) { - // 404 is expected when answer isn't available yet - if (!err.message?.includes('404')) { - console.error('Error polling for answer:', err) - } + 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 { + // We'll create a pool of offers manually + const offers = []; + const poolSize = 10; // Support up to 10 simultaneous 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}`; + await rondevu.publishService({ + serviceFqn: fqn, + offers, + ttl: 300000, // 5 minutes + }); + + setMyServicePublished(true); + console.log('✅ Chat service published with', poolSize, 'offers'); + } catch (err) { + console.error('Failed to publish service:', err); + toast.error(`Failed to publish service: ${err.message}`); + } + }; + + // 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' } - }, 1000) + })); - setAnswerPolling(interval) - } - - // Poll for ICE candidates (both offerer and answerer) - const startIcePolling = (service, fqn, offerId, pc, targetRole) => { - const interval = setInterval(async () => { - try { - const result = await service.getOfferIceCandidates( - fqn, - offerId, - lastIceTimestamp.current - ) - - for (const item of result.candidates) { - if (item.candidate && item.candidate.candidate) { - try { - const rtcCandidate = new RTCIceCandidate(item.candidate) - console.log('Received ICE 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) { - // 404/410 means offer expired - if (err.message?.includes('404') || err.message?.includes('410')) { - console.warn('Offer expired, stopping ICE polling') - clearInterval(interval) - setIcePolling(null) - } else if (!err.message?.includes('404')) { - console.error('Error polling for ICE candidates:', err) - } + // 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() + }] } - }, 1000) + })); + } + } catch (err) { + console.error('Failed to parse message:', err); + } + }; - setIcePolling(interval) + 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; } - // Claim username - const handleClaimUsername = async () => { - if (!rondevu || !usernameInput) 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 keypair = rondevu.getKeypair() - const newService = new Rondevu({ - apiUrl: API_URL, - username: usernameInput, - keypair, - }) - await newService.initialize() - await newService.claimUsername() + const result = await rondevu.getOfferIceCandidates( + fqn, + serviceData.offerId, + lastIceTimestamp.current + ); - setRondevu(newService) - setMyUsername(usernameInput) - localStorage.setItem('rondevu-username', usernameInput) - localStorage.setItem('rondevu-keypair', JSON.stringify(keypair)) - - setSetupStep('ready') - toast.success(`Welcome, ${usernameInput}!`) - - // Publish service - await publishService(newService, usernameInput) - } catch (err) { - toast.error(`Error: ${err.message}`) - } - } - - // Connect to peer (answerer) - const handleConnectToPeer = async () => { - if (!rondevu || !peerUsername) return - - try { - setConnectionState('connecting') - toast.loading('Connecting to peer...') - - // Discover peer's service - const fqn = `${CHAT_SERVICE}@${peerUsername}` - const serviceData = await rondevu.getService(fqn) - - console.log('Found peer service:', serviceData) - setServiceFqn(fqn) - setOfferId(serviceData.offerId) - - // Create peer connection - const pc = new RTCPeerConnection(RTC_CONFIG) - setupPeerConnection(pc) - - // Handle incoming data channel - pc.ondatachannel = (event) => { - console.log('Received data channel') - setupDataChannel(event.channel) - setDataChannel(event.channel) + 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; } - - // 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 - startIcePolling(rondevu, fqn, serviceData.offerId, pc, 'answerer') - - // Send local ICE candidates - pc.onicecandidate = (event) => { - if (event.candidate) { - console.log('Sending ICE candidate') - rondevu.addOfferIceCandidates( - fqn, - serviceData.offerId, - [event.candidate.toJSON()] - ).catch(err => console.error('Failed to send ICE candidate:', err)) - } - } - - setPeerConnection(pc) - setRole('answerer') - - toast.dismiss() - toast.success('Answer sent! Waiting for connection...') + } } catch (err) { - console.error('Failed to connect:', err) - toast.dismiss() - toast.error(`Failed to connect: ${err.message}`) - setConnectionState('disconnected') + 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 peer connection event handlers - const setupPeerConnection = (pc) => { - pc.onconnectionstatechange = () => { - console.log('Connection state:', pc.connectionState) - setConnectionState(pc.connectionState) + // Setup client data channel + const setupClientChannel = (dc, contact, pc) => { + dc.onopen = () => { + console.log('Client data channel opened with', contact); - if (pc.connectionState === 'connected') { - toast.success('Connected!') - } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { - toast.error('Connection failed or disconnected') + // 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); + } + }; - pc.oniceconnectionstatechange = () => { - console.log('ICE connection state:', pc.iceConnectionState) - } + // Update the channel reference in state + setActiveChats(prev => ({ + ...prev, + [contact]: { + ...prev[contact], + channel: dc + } + })); + }; - pc.onicegatheringstatechange = () => { - console.log('ICE gathering state:', pc.iceGatheringState) - } + // 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; } - // Setup data channel event handlers - const setupDataChannel = (dc) => { - dc.onopen = () => { - console.log('Data channel opened') - setConnectionState('connected') - } + try { + chat.channel.send(JSON.stringify({ + type: 'message', + text + })); - dc.onclose = () => { - console.log('Data channel closed') - setConnectionState('disconnected') + setActiveChats(prev => ({ + ...prev, + [contact]: { + ...prev[contact], + messages: [...prev[contact].messages, { + from: myUsername, + text, + timestamp: Date.now() + }] } + })); - dc.onmessage = (event) => { - console.log('Received message:', event.data) - setMessages(prev => [...prev, { from: 'peer', text: event.data, timestamp: Date.now() }]) - } - - dc.onerror = (err) => { - console.error('Data channel error:', err) - } + setMessageInputs(prev => ({ ...prev, [contact]: '' })); + } catch (err) { + console.error('Failed to send message:', err); + toast.error('Failed to send message'); } + }; - // Send message - const handleSendMessage = () => { - if (!dataChannel || !messageInput.trim()) return - - if (dataChannel.readyState !== 'open') { - toast.error('Data channel not open') - return - } - - try { - dataChannel.send(messageInput) - setMessages(prev => [...prev, { from: 'me', text: messageInput, timestamp: Date.now() }]) - setMessageInput('') - } 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(); } + }; - // Cleanup - const handleDisconnect = () => { - if (peerConnection) { - peerConnection.close() - } - if (dataChannel) { - dataChannel.close() - } - if (answerPolling) { - clearInterval(answerPolling) - setAnswerPolling(null) - } - if (icePolling) { - clearInterval(icePolling) - setIcePolling(null) - } - setPeerConnection(null) - setDataChannel(null) - setConnectionState('disconnected') - setMessages([]) - setPeerUsername('') - setRole(null) - setServiceFqn(null) - setOfferId(null) - lastIceTimestamp.current = 0 - toast.success('Disconnected') - } + if (!rondevu) { + return
- Role: - {role === 'offerer' ? 'Offerer (Hosting)' : role === 'answerer' ? 'Answerer' : 'Waiting'} - -
- - {connectionState === 'disconnected' && !role && ( -Connecting...
-No messages yet. Start chatting!
- )} - {messages.map((msg, i) => ( -
+ {JSON.stringify(getCurrentRtcConfig(), null, 2)}
+
+ 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 + /> + ++ 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 +
+