Files
rondevu-demo/src/App.jsx
Bas van den Aakster 5789fc636b fix: Create Rondevu instance in handleClaimUsername for new users
The previous change broke the claim flow for new users because handleClaimUsername
expected rondevu to exist, but we stopped creating it during initialization for
new users. Now handleClaimUsername creates the Rondevu instance itself.
2025-12-12 12:05:45 +01:00

1747 lines
53 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// Generate a random username suggestion
const generateRandomUsername = () => {
const timestamp = Date.now().toString(36);
const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
.map(b => b.toString(16).padStart(2, '0')).join('');
return `anon-${timestamp}-${random}`;
};
const [usernameInput, setUsernameInput] = useState(generateRandomUsername());
// 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;
// Check if we have a saved username and if it's claimed
if (savedUsername && savedKeypair) {
// Create Rondevu instance with saved credentials
const service = new Rondevu({
apiUrl: API_URL,
username: savedUsername,
keypair: parsedKeypair,
});
await service.initialize();
setRondevu(service);
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 {
// No saved username, prompt user to claim one
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]);
// Combined polling for answers and ICE candidates (host side)
useEffect(() => {
if (!myServicePublished || !rondevu || Object.keys(offerIdToPeerConnection).length === 0) {
return;
}
console.log('[Host Polling] Starting combined polling for answers and ICE candidates...');
const poll = async () => {
try {
const result = await rondevu.pollOffers(lastAnswerTimestamp);
// Process answers
if (result.answers.length > 0) {
console.log(`[Host Polling] Found ${result.answers.length} new answer(s)`);
for (const answer of result.answers) {
const pc = offerIdToPeerConnection[answer.offerId];
if (pc && pc.signalingState !== 'stable') {
console.log(`[Host 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(`✅ [Host Polling] Remote answer set for offer ${answer.offerId}`);
} else if (pc) {
console.log(`[Host Polling] Skipping offer ${answer.offerId} - already in stable state`);
}
}
}
// Process ICE candidates
let totalIceCandidates = 0;
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
const pc = offerIdToPeerConnection[offerId];
if (pc && candidates.length > 0) {
// Filter for answerer candidates only (offerer doesn't need their own candidates back)
const answererCandidates = candidates.filter(item => item.role === 'answerer');
if (answererCandidates.length > 0) {
console.log(`[Host Polling] Processing ${answererCandidates.length} answerer ICE candidate(s) for offer ${offerId}`);
for (const item of answererCandidates) {
if (item.candidate) {
try {
await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
totalIceCandidates++;
// Update timestamp
setLastAnswerTimestamp(prev => Math.max(prev, item.createdAt));
} catch (err) {
console.warn(`[Host Polling] Failed to add ICE candidate for offer ${offerId}:`, err);
}
}
}
}
}
}
if (totalIceCandidates > 0) {
console.log(`✅ [Host Polling] Added ${totalIceCandidates} answerer ICE candidate(s)`);
}
} catch (err) {
console.error('[Host Polling] Error polling:', err);
}
};
// Poll every 2 seconds
const interval = setInterval(poll, 2000);
poll(); // 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 (!usernameInput) return;
try {
// Create new Rondevu instance (will generate keypair if needed)
const newService = new Rondevu({
apiUrl: API_URL,
username: usernameInput,
keypair: rondevu?.getKeypair(), // Use existing keypair if available, otherwise will generate new one
});
await newService.initialize();
await newService.claimUsername();
const keypair = newService.getKeypair();
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 (will be enhanced with offerId later)
setupHostConnection(pc, dc, myUsername);
// Buffer ICE candidates until we have offerId
const candidateBuffer = [];
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log(`[Host] Buffering ICE candidate for connection ${i}:`, event.candidate);
candidateBuffer.push(event.candidate.toJSON());
} else {
console.log(`[Host] ICE gathering complete for connection ${i} (buffered ${candidateBuffer.length} candidates)`);
}
};
// Create offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
offers.push({ sdp: offer.sdp });
connections.push({ pc, dc, index: i, candidateBuffer });
}
// 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 and setup ICE handlers
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' };
// Send buffered ICE candidates
if (conn.candidateBuffer && conn.candidateBuffer.length > 0) {
console.log(`[Host] Sending ${conn.candidateBuffer.length} buffered ICE candidates for offer ${offer.offerId}`);
rondevu.addOfferIceCandidates(
fqn,
offer.offerId,
conn.candidateBuffer
).then(() => {
console.log(`✅ [Host] Successfully sent ${conn.candidateBuffer.length} buffered ICE candidates for offer ${offer.offerId}`);
}).catch(err => console.error(`[Host] Failed to send buffered ICE candidates for offer ${offer.offerId}:`, err));
}
// Setup ICE candidate handler for any future candidates
conn.pc.onicecandidate = (event) => {
if (event.candidate) {
console.log(`[Host] Sending new ICE candidate for offer ${offer.offerId}:`, event.candidate);
rondevu.addOfferIceCandidates(
fqn,
offer.offerId,
[event.candidate.toJSON()]
).then(() => {
console.log(`✅ [Host] Successfully sent ICE candidate for offer ${offer.offerId}`);
}).catch(err => console.error(`[Host] Failed to send ICE candidate for offer ${offer.offerId}:`, err));
} else {
console.log(`[Host] ICE gathering complete for offer ${offer.offerId} (null candidate)`);
}
};
});
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 connection from: ${peerUsername}`);
// Auto-accept and open chat immediately (same UX as answerer side)
setActiveChats(prev => ({
...prev,
[peerUsername]: {
username: peerUsername,
channel: dc,
connection: pc,
messages: prev[peerUsername]?.messages || [],
status: 'connected',
role: 'host'
}
}));
// Auto-select the chat
setSelectedChat(peerUsername);
// Send acknowledgment
dc.send(JSON.stringify({
type: 'identify_ack',
from: hostUsername
}));
// Show notification
toast.success(`${peerUsername} connected!`, {
duration: 3000,
icon: '💬'
});
} 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 };
console.log(`[Answerer] Starting ICE candidate polling for offer ${serviceData.offerId}`);
const icePolling = setInterval(async () => {
try {
const result = await rondevu.getOfferIceCandidates(
fqn,
serviceData.offerId,
lastIceTimestamp.current
);
if (result.candidates.length > 0) {
console.log(`[Answerer] Received ${result.candidates.length} ICE candidate(s) from offerer`);
}
for (const item of result.candidates) {
if (item.candidate && item.candidate.candidate) {
try {
console.log(`[Answerer] Adding offerer ICE candidate:`, item.candidate);
const rtcCandidate = new RTCIceCandidate(item.candidate);
await pc.addIceCandidate(rtcCandidate);
lastIceTimestamp.current = item.createdAt;
console.log(`✅ [Answerer] Successfully added offerer ICE candidate`);
} catch (err) {
console.warn('[Answerer] 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('[Answerer] Offer expired, stopping ICE polling');
clearInterval(icePolling);
} else {
console.error('[Answerer] Error polling ICE candidates:', err);
}
}
}, 1000);
// Send local ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log(`[Answerer] Sending ICE candidate to server:`, event.candidate);
rondevu.addOfferIceCandidates(
fqn,
serviceData.offerId,
[event.candidate.toJSON()]
).then(() => {
console.log(`✅ [Answerer] Successfully sent ICE candidate to server`);
}).catch(err => console.error('[Answerer] 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' });
// Stop ICE polling once connected
clearInterval(icePolling);
console.log('[Answerer] Stopped ICE polling - connection established');
} 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 <div style={styles.loading}>Loading...</div>;
}
return (
<div style={styles.container}>
<Toaster position="top-right" />
{/* Settings Modal */}
{showSettings && (
<div style={styles.modalOverlay} onClick={() => setShowSettings(false)}>
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<h2 style={styles.modalTitle}>WebRTC Configuration</h2>
<div style={styles.settingsSection}>
<label style={styles.settingsLabel}>
Preset Configuration:
</label>
<select
value={rtcPreset}
onChange={(e) => setRtcPreset(e.target.value)}
style={styles.settingsSelect}
>
{Object.entries(RTC_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.name}
</option>
))}
</select>
</div>
{rtcPreset === 'custom' && (
<div style={styles.settingsSection}>
<label style={styles.settingsLabel}>
Custom RTC Configuration (JSON):
</label>
<textarea
value={customRtcConfig}
onChange={(e) => setCustomRtcConfig(e.target.value)}
placeholder={JSON.stringify(RTC_PRESETS['ipv4-turn'].config, null, 2)}
style={styles.settingsTextarea}
rows={15}
/>
<p style={styles.settingsHint}>
Enter valid RTCConfiguration JSON
</p>
</div>
)}
{rtcPreset !== 'custom' && (
<div style={styles.settingsSection}>
<label style={styles.settingsLabel}>
Current Configuration:
</label>
<pre style={styles.settingsPreview}>
{JSON.stringify(getCurrentRtcConfig(), null, 2)}
</pre>
</div>
)}
<div style={styles.modalActions}>
<button onClick={() => setShowSettings(false)} style={styles.modalBtn}>
Close
</button>
</div>
</div>
</div>
)}
{/* Setup Screen */}
{setupStep !== 'ready' && (
<div style={styles.setupScreen}>
<div style={styles.setupBox}>
<h1 style={styles.setupTitle}>Rondevu Chat</h1>
<p style={styles.setupSubtitle}>Decentralized P2P Chat</p>
{setupStep === 'init' && (
<div>
<p style={styles.setupDesc}>Initializing...</p>
</div>
)}
{setupStep === 'claim' && (
<div>
<p style={styles.setupDesc}>Choose your unique username</p>
<input
type="text"
placeholder="Choose a username"
value={usernameInput}
onChange={(e) => setUsernameInput(e.target.value.toLowerCase())}
onKeyPress={(e) => e.key === 'Enter' && handleClaimUsername()}
style={styles.setupInput}
autoFocus
/>
<button
onClick={handleClaimUsername}
disabled={!usernameInput}
style={styles.setupButton}
>
Claim Username
</button>
<p style={styles.setupHint}>
3-32 characters, lowercase letters, numbers, and dashes only
</p>
</div>
)}
</div>
</div>
)}
{/* Main Chat Screen */}
{setupStep === 'ready' && (
<div style={styles.mainScreen}>
{/* Sidebar */}
<div style={styles.sidebar}>
{/* User Header */}
<div style={styles.userHeader}>
<div>
<div style={styles.userHeaderName}>@{myUsername}</div>
<div style={styles.userHeaderStatus}>
<span style={styles.onlineDot}></span> Online
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setShowSettings(true)} style={styles.settingsBtn} title="Settings">
</button>
<button onClick={handleLogout} style={styles.logoutBtn} title="Logout">
Logout
</button>
</div>
</div>
{/* Add Contact */}
<div style={styles.addContactBox}>
<input
type="text"
placeholder="Add friend by username..."
value={contactInput}
onChange={(e) => setContactInput(e.target.value.toLowerCase())}
onKeyPress={(e) => e.key === 'Enter' && handleAddContact()}
style={styles.contactInput}
/>
<button onClick={handleAddContact} style={styles.addBtn} title="Add friend">
Add
</button>
</div>
{/* Incoming Chats (not in contacts) */}
{Object.keys(activeChats).filter(username => !contacts.includes(username) && activeChats[username].status === 'connected').length > 0 && (
<div style={styles.contactsList}>
<div style={styles.contactsHeader}>
Active Chats ({Object.keys(activeChats).filter(username => !contacts.includes(username) && activeChats[username].status === 'connected').length})
</div>
{Object.keys(activeChats)
.filter(username => !contacts.includes(username) && activeChats[username].status === 'connected')
.map(contact => (
<div
key={contact}
className="contact-item"
style={{
...styles.contactItem,
...(selectedChat === contact ? styles.contactItemActive : {})
}}
onClick={() => setSelectedChat(contact)}
>
<div style={styles.contactAvatar}>
{contact[0].toUpperCase()}
<span style={{
...styles.contactDot,
background: '#4caf50'
}}></span>
</div>
<div style={{ flex: 1 }}>
<div style={styles.contactName}>{contact}</div>
<div style={styles.contactStatus}>
Connected
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
// Close/disconnect the chat
const chat = activeChats[contact];
if (chat) {
try {
chat.channel?.close();
chat.connection?.close();
} catch (err) {
console.error('Error closing chat:', err);
}
setActiveChats(prev => {
const updated = { ...prev };
delete updated[contact];
return updated;
});
if (selectedChat === contact) {
setSelectedChat(null);
}
toast.success(`Disconnected from ${contact}`);
}
}}
style={{
...styles.removeBtn,
background: '#dc3545'
}}
title="Disconnect"
>
</button>
</div>
))}
</div>
)}
{/* Contacts List */}
<div style={styles.contactsList}>
<div style={styles.contactsHeader}>
Friends ({contacts.length})
</div>
{contacts.length === 0 ? (
<div style={styles.emptyState}>
<p>No friends yet</p>
<p style={{ fontSize: '12px', color: '#999' }}>
Add friends by their username above
</p>
</div>
) : (
contacts.map(contact => {
const isOnline = onlineUsers.has(contact);
const hasActiveChat = activeChats[contact]?.status === 'connected';
return (
<div
key={contact}
className="contact-item"
style={{
...styles.contactItem,
...(selectedChat === contact ? styles.contactItemActive : {})
}}
onClick={() => hasActiveChat ? setSelectedChat(contact) : handleStartChat(contact)}
>
<div style={styles.contactAvatar}>
{contact[0].toUpperCase()}
<span style={{
...styles.contactDot,
background: isOnline ? '#4caf50' : '#999'
}}></span>
</div>
<div style={{ flex: 1 }}>
<div style={styles.contactName}>{contact}</div>
<div style={styles.contactStatus}>
{hasActiveChat ? 'Chatting' : isOnline ? 'Online' : 'Offline'}
</div>
</div>
{hasActiveChat && (
<button
onClick={(e) => {
e.stopPropagation();
// Close the active chat
const chat = activeChats[contact];
if (chat) {
try {
chat.channel?.close();
chat.connection?.close();
} catch (err) {
console.error('Error closing chat:', err);
}
setActiveChats(prev => {
const updated = { ...prev };
delete updated[contact];
return updated;
});
if (selectedChat === contact) {
setSelectedChat(null);
}
toast.success(`Chat with ${contact} closed`);
}
}}
style={{
...styles.removeBtn,
background: '#dc3545',
marginRight: '4px'
}}
title="End chat"
>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`Remove ${contact} from your friends list?`)) {
handleRemoveContact(contact);
}
}}
style={styles.removeBtn}
title="Remove friend"
>
🗑
</button>
</div>
);
})
)}
</div>
</div>
{/* Chat Area */}
<div style={styles.chatArea}>
{!selectedChat ? (
<div style={styles.emptyChat}>
<h2 style={{ margin: 0 }}>Select a friend to chat</h2>
<p style={{ marginTop: '10px' }}>
Click on a friend from the sidebar to start chatting
</p>
</div>
) : (
<>
{/* Chat Header */}
<div style={styles.chatHeader}>
<div style={styles.chatHeaderAvatar}>
{selectedChat[0].toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<div style={styles.chatHeaderName}>@{selectedChat}</div>
<div style={styles.chatHeaderStatus}>
{activeChats[selectedChat]?.status === 'connected' ? (
<><span style={styles.onlineDot}></span> Connected</>
) : (
'Connecting...'
)}
</div>
</div>
<button
onClick={() => setSelectedChat(null)}
style={styles.closeChatBtn}
title="Close chat"
>
</button>
</div>
{/* Messages */}
<div style={styles.messagesArea}>
{(!activeChats[selectedChat] || activeChats[selectedChat].messages.length === 0) ? (
<div style={styles.emptyMessages}>
<p>No messages yet</p>
<p style={{ fontSize: '12px', color: '#999' }}>
Send a message to start the conversation
</p>
</div>
) : (
activeChats[selectedChat].messages.map((msg, idx) => (
<div
key={idx}
style={{
...styles.message,
...(msg.from === myUsername ? styles.messageMe : styles.messageThem)
}}
>
<div style={{
...styles.messageText,
background: msg.from === myUsername ? '#4a9eff' : '#2a2a2a',
color: 'white'
}}>
{msg.text}
</div>
<div style={styles.messageTime}>
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
))
)}
<div ref={chatEndRef} />
</div>
{/* Input */}
<div style={styles.inputArea}>
<input
type="text"
placeholder="Type a message..."
value={messageInputs[selectedChat] || ''}
onChange={(e) => setMessageInputs(prev => ({
...prev,
[selectedChat]: e.target.value
}))}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(selectedChat);
}
}}
disabled={activeChats[selectedChat]?.status !== 'connected'}
style={styles.messageInput}
autoFocus
/>
<button
onClick={() => handleSendMessage(selectedChat)}
disabled={!messageInputs[selectedChat] || activeChats[selectedChat]?.status !== 'connected'}
style={styles.sendBtn}
title="Send message"
>
Send
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
const styles = {
container: {
height: '100vh',
background: '#1a1a1a',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
loading: {
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#e0e0e0',
fontSize: '24px'
},
setupScreen: {
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
},
setupBox: {
background: '#2a2a2a',
borderRadius: '16px',
padding: '40px',
maxWidth: '400px',
width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
textAlign: 'center',
border: '1px solid #3a3a3a'
},
setupTitle: {
fontSize: '2.5em',
margin: '0 0 10px 0',
color: '#e0e0e0'
},
setupSubtitle: {
fontSize: '1.1em',
color: '#a0a0a0',
margin: '0 0 30px 0'
},
setupDesc: {
color: '#a0a0a0',
marginBottom: '20px'
},
setupInput: {
width: '100%',
padding: '15px',
fontSize: '16px',
border: '1px solid #3a3a3a',
background: '#1a1a1a',
color: '#e0e0e0',
borderRadius: '8px',
marginBottom: '15px',
boxSizing: 'border-box',
outline: 'none',
},
setupButton: {
width: '100%',
padding: '15px',
fontSize: '16px',
fontWeight: '600',
background: '#4a9eff',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
},
setupHint: {
fontSize: '12px',
color: '#808080',
marginTop: '10px'
},
mainScreen: {
height: '100vh',
display: 'flex'
},
sidebar: {
width: '320px',
background: '#2a2a2a',
borderRight: '1px solid #3a3a3a',
display: 'flex',
flexDirection: 'column'
},
userHeader: {
padding: '20px',
borderBottom: '1px solid #3a3a3a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
},
userHeaderName: {
fontSize: '18px',
fontWeight: '600',
color: '#e0e0e0'
},
userHeaderStatus: {
fontSize: '12px',
color: '#a0a0a0',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '5px'
},
onlineDot: {
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#4caf50',
display: 'inline-block'
},
settingsBtn: {
padding: '8px 12px',
background: '#3a3a3a',
color: '#e0e0e0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '18px',
lineHeight: '1'
},
logoutBtn: {
padding: '8px 12px',
background: '#3a3a3a',
color: '#e0e0e0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
},
addContactBox: {
padding: '15px',
borderBottom: '1px solid #3a3a3a',
display: 'flex',
gap: '8px'
},
contactInput: {
flex: 1,
padding: '10px',
border: '1px solid #3a3a3a',
background: '#1a1a1a',
color: '#e0e0e0',
borderRadius: '6px',
fontSize: '14px',
outline: 'none'
},
addBtn: {
padding: '10px 15px',
background: '#4a9eff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
},
contactsList: {
flex: 1,
overflowY: 'auto'
},
contactsHeader: {
padding: '15px 20px',
fontSize: '12px',
fontWeight: '600',
color: '#808080',
textTransform: 'uppercase',
letterSpacing: '0.5px'
},
emptyState: {
padding: '40px 20px',
textAlign: 'center',
color: '#808080'
},
contactItem: {
padding: '15px 20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
transition: 'background 0.2s',
borderBottom: '1px solid #3a3a3a'
},
contactItemActive: {
background: '#3a3a3a'
},
contactAvatar: {
width: '40px',
height: '40px',
borderRadius: '50%',
background: '#4a9eff',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
fontWeight: '600',
position: 'relative'
},
contactDot: {
position: 'absolute',
bottom: '0',
right: '0',
width: '12px',
height: '12px',
borderRadius: '50%',
border: '2px solid #2a2a2a'
},
contactName: {
fontSize: '15px',
fontWeight: '600',
color: '#e0e0e0'
},
contactStatus: {
fontSize: '12px',
color: '#a0a0a0',
marginTop: '2px'
},
removeBtn: {
padding: '4px 8px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: '16px',
color: '#808080',
opacity: 0.6,
},
chatArea: {
flex: 1,
background: '#1a1a1a',
display: 'flex',
flexDirection: 'column'
},
emptyChat: {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#808080'
},
chatHeader: {
padding: '20px',
borderBottom: '1px solid #3a3a3a',
display: 'flex',
alignItems: 'center',
gap: '15px',
background: '#2a2a2a'
},
chatHeaderAvatar: {
width: '48px',
height: '48px',
borderRadius: '50%',
background: '#4a9eff',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
fontWeight: '600'
},
chatHeaderName: {
fontSize: '18px',
fontWeight: '600',
color: '#e0e0e0'
},
chatHeaderStatus: {
fontSize: '13px',
color: '#a0a0a0',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '5px'
},
closeChatBtn: {
padding: '8px 12px',
background: '#3a3a3a',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
color: '#e0e0e0'
},
messagesArea: {
flex: 1,
overflowY: 'auto',
padding: '20px',
background: '#1a1a1a'
},
emptyMessages: {
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#808080'
},
message: {
marginBottom: '12px',
display: 'flex',
flexDirection: 'column',
maxWidth: '70%'
},
messageMe: {
alignSelf: 'flex-end',
alignItems: 'flex-end'
},
messageThem: {
alignSelf: 'flex-start',
alignItems: 'flex-start'
},
messageText: {
padding: '12px 16px',
borderRadius: '16px',
fontSize: '15px',
lineHeight: '1.4',
wordWrap: 'break-word',
boxShadow: '0 1px 2px rgba(0,0,0,0.2)'
},
messageTime: {
fontSize: '11px',
color: '#808080',
marginTop: '4px',
padding: '0 8px'
},
inputArea: {
padding: '20px',
borderTop: '1px solid #3a3a3a',
display: 'flex',
gap: '12px',
background: '#2a2a2a'
},
messageInput: {
flex: 1,
padding: '12px 16px',
border: '1px solid #3a3a3a',
background: '#1a1a1a',
color: '#e0e0e0',
borderRadius: '24px',
fontSize: '15px',
outline: 'none',
},
sendBtn: {
padding: '12px 24px',
borderRadius: '24px',
background: '#4a9eff',
color: 'white',
border: 'none',
cursor: 'pointer',
fontSize: '15px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
},
modalContent: {
background: '#2a2a2a',
borderRadius: '12px',
padding: '24px',
maxWidth: '600px',
width: '90%',
maxHeight: '80vh',
overflowY: 'auto',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)'
},
modalTitle: {
fontSize: '24px',
color: '#e0e0e0',
marginBottom: '20px',
fontWeight: '600'
},
settingsSection: {
marginBottom: '20px'
},
settingsLabel: {
display: 'block',
color: '#e0e0e0',
marginBottom: '8px',
fontSize: '14px',
fontWeight: '500'
},
settingsSelect: {
width: '100%',
padding: '10px',
background: '#1a1a1a',
color: '#e0e0e0',
border: '1px solid #3a3a3a',
borderRadius: '6px',
fontSize: '14px',
outline: 'none',
cursor: 'pointer'
},
settingsTextarea: {
width: '100%',
padding: '12px',
background: '#1a1a1a',
color: '#e0e0e0',
border: '1px solid #3a3a3a',
borderRadius: '6px',
fontSize: '13px',
fontFamily: 'monospace',
outline: 'none',
resize: 'vertical'
},
settingsPreview: {
width: '100%',
padding: '12px',
background: '#1a1a1a',
color: '#4a9eff',
border: '1px solid #3a3a3a',
borderRadius: '6px',
fontSize: '13px',
fontFamily: 'monospace',
overflowX: 'auto',
margin: 0
},
settingsHint: {
fontSize: '12px',
color: '#808080',
marginTop: '6px'
},
modalActions: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
marginTop: '24px'
},
modalBtn: {
padding: '10px 20px',
background: '#4a9eff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}
};
// Add hover effects via CSS
if (typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = `
button:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.02);
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.contact-item:hover {
background: #333333 !important;
}
.contact-item:active {
background: #2a2a2a !important;
}
input:focus {
border-color: #4a9eff !important;
}
`;
document.head.appendChild(style);
}