diff --git a/package-lock.json b/package-lock.json index 9fc2f4f..1da307d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "rondevu-demo", "version": "2.0.0", "dependencies": { - "@xtr-dev/rondevu-client": "^0.10.0", + "@xtr-dev/rondevu-client": "^0.10.1", "@zxing/library": "^0.21.3", "qrcode": "^1.5.4", "react": "^18.2.0", @@ -1171,9 +1171,9 @@ } }, "node_modules/@xtr-dev/rondevu-client": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.10.0.tgz", - "integrity": "sha512-wCU2GhW9vhG9SDElSYdyhcvpueHcpHuOpwdqxHYgvJ/4NCHT1aIa0uHqEwLtF+aoUHbqExpLaUD8DZEnwFkZQQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.10.1.tgz", + "integrity": "sha512-pQpUH7CYTiIShYplp/rv5Un0r08vDnMK2YhiNTRAc2jNzffDm+WxphalkxnmmZTm5sTMYCH9X7IQeSRmvTHcxg==", "license": "MIT", "dependencies": { "@noble/ed25519": "^3.0.0" diff --git a/package.json b/package.json index 67ef6f9..baf28a2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo" }, "dependencies": { - "@xtr-dev/rondevu-client": "^0.10.0", + "@xtr-dev/rondevu-client": "^0.10.1", "@zxing/library": "^0.21.3", "qrcode": "^1.5.4", "react": "^18.2.0", diff --git a/src/App-old.jsx b/src/App-old.jsx new file mode 100644 index 0000000..5c0bf56 --- /dev/null +++ b/src/App-old.jsx @@ -0,0 +1,1368 @@ +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'; + +// 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 [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); + + // 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]); + + // 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: getCurrentRtcConfig(), + 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: getCurrentRtcConfig(), + maxReconnectAttempts: 5 + }); + + // Create data channel (must match service pool's channel name) + const channel = connection.createChannel('rondevu-service'); + + // 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
+ {JSON.stringify(getCurrentRtcConfig(), null, 2)}
+
+ 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 +
+
- {JSON.stringify(getCurrentRtcConfig(), null, 2)}
-
- 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 -
-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
+No messages yet
-- Send a message to start the conversation -
-