mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 02:43:23 +00:00
Compare commits
9 Commits
c511b15fbf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a24514e7b | |||
| 46f0eb2e7a | |||
| 9e26ed3b66 | |||
| 7835ebd35d | |||
| 5f223356ba | |||
| ab55a96fac | |||
| 158e001055 | |||
| 9967e8d762 | |||
| 8c3f21f262 |
41
package-lock.json
generated
41
package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "rondevu-demo",
|
"name": "rondevu-demo",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xtr-dev/rondevu-client": "^0.9.2",
|
"@xtr-dev/rondevu-client": "file:../client",
|
||||||
"@zxing/library": "^0.21.3",
|
"@zxing/library": "^0.21.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -22,6 +22,27 @@
|
|||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../client": {
|
||||||
|
"name": "@xtr-dev/rondevu-client",
|
||||||
|
"version": "0.13.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ed25519": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||||
|
"@typescript-eslint/parser": "^8.48.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -745,15 +766,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/ed25519": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1171,13 +1183,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xtr-dev/rondevu-client": {
|
"node_modules/@xtr-dev/rondevu-client": {
|
||||||
"version": "0.9.2",
|
"resolved": "../client",
|
||||||
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.9.2.tgz",
|
"link": true
|
||||||
"integrity": "sha512-DVow5AOPU40dqQtlfQK7J2GNX8dz2/4UzltMqublaPZubbkRYgocvp0b76NQu5F6v150IstMV2N49uxAYqogVw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@noble/ed25519": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@zxing/library": {
|
"node_modules/@zxing/library": {
|
||||||
"version": "0.21.3",
|
"version": "0.21.3",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
|
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xtr-dev/rondevu-client": "^0.9.2",
|
"@xtr-dev/rondevu-client": "file:../client",
|
||||||
"@zxing/library": "^0.21.3",
|
"@zxing/library": "^0.21.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
1368
src/App-old.jsx
Normal file
1368
src/App-old.jsx
Normal file
File diff suppressed because it is too large
Load Diff
588
src/App.jsx
588
src/App.jsx
@@ -3,6 +3,7 @@ import { Rondevu } from '@xtr-dev/rondevu-client';
|
|||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
const API_URL = 'https://api.ronde.vu';
|
const API_URL = 'https://api.ronde.vu';
|
||||||
|
const CHAT_SERVICE = 'chat:2.0.0';
|
||||||
|
|
||||||
// Preset RTC configurations
|
// Preset RTC configurations
|
||||||
const RTC_PRESETS = {
|
const RTC_PRESETS = {
|
||||||
@@ -73,12 +74,11 @@ const RTC_PRESETS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [client, setClient] = useState(null);
|
const [rondevu, setRondevu] = useState(null);
|
||||||
const [credentials, setCredentials] = useState(null);
|
|
||||||
const [myUsername, setMyUsername] = useState(null);
|
const [myUsername, setMyUsername] = useState(null);
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
const [setupStep, setSetupStep] = useState('register'); // register, claim, ready
|
const [setupStep, setSetupStep] = useState('init'); // init, claim, ready
|
||||||
const [usernameInput, setUsernameInput] = useState('');
|
const [usernameInput, setUsernameInput] = useState('');
|
||||||
|
|
||||||
// Contacts
|
// Contacts
|
||||||
@@ -86,13 +86,15 @@ export default function App() {
|
|||||||
const [contactInput, setContactInput] = useState('');
|
const [contactInput, setContactInput] = useState('');
|
||||||
const [onlineUsers, setOnlineUsers] = useState(new Set());
|
const [onlineUsers, setOnlineUsers] = useState(new Set());
|
||||||
|
|
||||||
// Chat
|
// Chat - structure: { [username]: { connection, channel, messages, status, role, serviceFqn, offerId, polling } }
|
||||||
const [activeChats, setActiveChats] = useState({});
|
const [activeChats, setActiveChats] = useState({});
|
||||||
const [selectedChat, setSelectedChat] = useState(null);
|
const [selectedChat, setSelectedChat] = useState(null);
|
||||||
const [messageInputs, setMessageInputs] = useState({});
|
const [messageInputs, setMessageInputs] = useState({});
|
||||||
|
|
||||||
// Service
|
// Service - we publish one service that can accept multiple connections
|
||||||
const [serviceHandle, setServiceHandle] = useState(null);
|
const [myServicePublished, setMyServicePublished] = useState(false);
|
||||||
|
const [hostConnections, setHostConnections] = useState({}); // Track incoming connections as host
|
||||||
|
|
||||||
const chatEndRef = useRef(null);
|
const chatEndRef = useRef(null);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -137,16 +139,18 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [customRtcConfig]);
|
}, [customRtcConfig]);
|
||||||
|
|
||||||
// Load saved data and auto-register
|
// Initialize Rondevu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedCreds = localStorage.getItem('rondevu-chat-credentials');
|
const init = async () => {
|
||||||
const savedUsername = localStorage.getItem('rondevu-chat-username');
|
try {
|
||||||
const savedContacts = localStorage.getItem('rondevu-chat-contacts');
|
const savedUsername = localStorage.getItem('rondevu-username');
|
||||||
|
const savedKeypair = localStorage.getItem('rondevu-keypair');
|
||||||
|
const savedContacts = localStorage.getItem('rondevu-contacts');
|
||||||
|
|
||||||
const initialize = async () => {
|
console.log('[Init] Saved username:', savedUsername);
|
||||||
let clientInstance;
|
console.log('[Init] Has saved keypair:', !!savedKeypair);
|
||||||
|
|
||||||
// Load contacts first
|
// Load contacts
|
||||||
if (savedContacts) {
|
if (savedContacts) {
|
||||||
try {
|
try {
|
||||||
setContacts(JSON.parse(savedContacts));
|
setContacts(JSON.parse(savedContacts));
|
||||||
@@ -155,56 +159,41 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle credentials
|
const parsedKeypair = savedKeypair ? JSON.parse(savedKeypair) : undefined;
|
||||||
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
|
const service = new Rondevu({
|
||||||
if (savedUsername) {
|
apiUrl: API_URL,
|
||||||
|
username: savedUsername || 'temp',
|
||||||
|
keypair: parsedKeypair,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
setRondevu(service);
|
||||||
|
|
||||||
|
if (savedUsername && savedKeypair) {
|
||||||
|
console.log('[Init] Checking if username is claimed...');
|
||||||
|
const isClaimed = await service.isUsernameClaimed();
|
||||||
|
console.log('[Init] Username claimed:', isClaimed);
|
||||||
|
|
||||||
|
if (isClaimed) {
|
||||||
setMyUsername(savedUsername);
|
setMyUsername(savedUsername);
|
||||||
setSetupStep('ready');
|
setSetupStep('ready');
|
||||||
|
toast.success(`Welcome back, ${savedUsername}!`);
|
||||||
|
} else {
|
||||||
|
console.warn('[Init] Username not claimed on server, need to claim');
|
||||||
|
setSetupStep('claim');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Have creds but no username - go to claim step
|
|
||||||
setSetupStep('claim');
|
setSetupStep('claim');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load credentials:', err);
|
console.error('Initialization failed:', err);
|
||||||
// Invalid saved creds - auto-register
|
toast.error(`Failed to initialize: ${err.message}`);
|
||||||
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');
|
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();
|
init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-scroll chat
|
// Auto-scroll chat
|
||||||
@@ -212,35 +201,26 @@ export default function App() {
|
|||||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [activeChats, selectedChat]);
|
}, [activeChats, selectedChat]);
|
||||||
|
|
||||||
// Start chat service when ready
|
// Publish service when ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (setupStep === 'ready' && myUsername && client && !serviceHandle) {
|
if (setupStep === 'ready' && myUsername && rondevu && !myServicePublished) {
|
||||||
startChatService();
|
publishMyService();
|
||||||
}
|
}
|
||||||
}, [setupStep, myUsername, client]);
|
}, [setupStep, myUsername, rondevu, myServicePublished]);
|
||||||
|
|
||||||
// Check online status periodically
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (setupStep !== 'ready' || !client) return;
|
if (setupStep !== 'ready' || !rondevu) return;
|
||||||
|
|
||||||
const checkOnlineStatus = async () => {
|
const checkOnlineStatus = async () => {
|
||||||
const online = new Set();
|
const online = new Set();
|
||||||
for (const contact of contacts) {
|
for (const contact of contacts) {
|
||||||
try {
|
try {
|
||||||
// Try to query the service via discovery endpoint
|
const fqn = `${CHAT_SERVICE}@${contact}`;
|
||||||
const response = await fetch(`${API_URL}/index/${contact}/query`, {
|
await rondevu.getService(fqn);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ serviceFqn: 'chat.rondevu@1.0.0' })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
online.add(contact);
|
online.add(contact);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User offline or doesn't exist
|
// User offline or doesn't have service published
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setOnlineUsers(online);
|
setOnlineUsers(online);
|
||||||
@@ -250,16 +230,27 @@ export default function App() {
|
|||||||
const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s
|
const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [contacts, setupStep, client]);
|
}, [contacts, setupStep, rondevu]);
|
||||||
|
|
||||||
// Claim username
|
// Claim username
|
||||||
const handleClaimUsername = async () => {
|
const handleClaimUsername = async () => {
|
||||||
if (!client || !usernameInput) return;
|
if (!rondevu || !usernameInput) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const claim = await client.usernames.claimUsername(usernameInput);
|
const keypair = rondevu.getKeypair();
|
||||||
client.usernames.saveKeypairToStorage(usernameInput, claim.publicKey, claim.privateKey);
|
const newService = new Rondevu({
|
||||||
|
apiUrl: API_URL,
|
||||||
|
username: usernameInput,
|
||||||
|
keypair,
|
||||||
|
});
|
||||||
|
await newService.initialize();
|
||||||
|
await newService.claimUsername();
|
||||||
|
|
||||||
|
setRondevu(newService);
|
||||||
setMyUsername(usernameInput);
|
setMyUsername(usernameInput);
|
||||||
localStorage.setItem('rondevu-chat-username', usernameInput);
|
localStorage.setItem('rondevu-username', usernameInput);
|
||||||
|
localStorage.setItem('rondevu-keypair', JSON.stringify(keypair));
|
||||||
|
|
||||||
setSetupStep('ready');
|
setSetupStep('ready');
|
||||||
toast.success(`Welcome, ${usernameInput}!`);
|
toast.success(`Welcome, ${usernameInput}!`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -267,122 +258,167 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start pooled chat service with durable connections
|
// Publish service to accept incoming connections
|
||||||
const startChatService = async () => {
|
const publishMyService = async () => {
|
||||||
if (!client || !myUsername || serviceHandle) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keypair = client.usernames.loadKeypairFromStorage(myUsername);
|
// Verify username is claimed with correct keypair before publishing
|
||||||
if (!keypair) {
|
console.log('[Publish] Verifying username claim...');
|
||||||
toast.error('Username keypair not found');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Publish] Failed to check username:', e);
|
||||||
|
}
|
||||||
|
|
||||||
const service = await client.exposeService({
|
// Try to claim username
|
||||||
username: myUsername,
|
console.log('[Publish] Attempting to claim username...');
|
||||||
privateKey: keypair.privateKey,
|
toast.loading('Claiming username...', { id: 'claim' });
|
||||||
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 {
|
try {
|
||||||
const msg = JSON.parse(data);
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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}`;
|
||||||
|
console.log('[Publish] Publishing service with FQN:', fqn);
|
||||||
|
console.log('[Publish] Public key:', rondevu.getPublicKey());
|
||||||
|
|
||||||
|
await rondevu.publishService({
|
||||||
|
serviceFqn: fqn,
|
||||||
|
offers,
|
||||||
|
ttl: 300000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
setMyServicePublished(true);
|
||||||
|
console.log('✅ Chat service published successfully with', poolSize, 'offers');
|
||||||
|
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') {
|
if (msg.type === 'identify') {
|
||||||
// Peer identified themselves
|
// Peer identified themselves
|
||||||
|
peerUsername = msg.from;
|
||||||
|
console.log(`📡 New chat connection from: ${peerUsername}`);
|
||||||
|
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[msg.from]: {
|
[peerUsername]: {
|
||||||
username: msg.from,
|
username: peerUsername,
|
||||||
channel,
|
channel: dc,
|
||||||
connectionId,
|
connection: pc,
|
||||||
messages: prev[msg.from]?.messages || [],
|
messages: prev[peerUsername]?.messages || [],
|
||||||
status: 'connected'
|
status: 'connected',
|
||||||
|
role: 'host'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Remove old handler and add new one for chat messages
|
// Send acknowledgment
|
||||||
channel.off('message');
|
dc.send(JSON.stringify({
|
||||||
channel.on('message', (chatData) => {
|
type: 'identify_ack',
|
||||||
try {
|
from: hostUsername
|
||||||
const chatMsg = JSON.parse(chatData);
|
}));
|
||||||
if (chatMsg.type === 'message') {
|
} else if (msg.type === 'message' && peerUsername) {
|
||||||
|
// Chat message
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[msg.from]: {
|
[peerUsername]: {
|
||||||
...prev[msg.from],
|
...prev[peerUsername],
|
||||||
messages: [...(prev[msg.from]?.messages || []), {
|
messages: [...(prev[peerUsername]?.messages || []), {
|
||||||
from: msg.from,
|
from: peerUsername,
|
||||||
text: chatMsg.text,
|
text: msg.text,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse chat message:', err);
|
console.error('Failed to parse message:', err);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// Send acknowledgment
|
pc.onconnectionstatechange = () => {
|
||||||
channel.send(JSON.stringify({
|
console.log('Host connection state:', pc.connectionState);
|
||||||
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
|
// Add contact
|
||||||
@@ -398,7 +434,7 @@ export default function App() {
|
|||||||
|
|
||||||
const newContacts = [...contacts, contactInput];
|
const newContacts = [...contacts, contactInput];
|
||||||
setContacts(newContacts);
|
setContacts(newContacts);
|
||||||
localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts));
|
localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts));
|
||||||
setContactInput('');
|
setContactInput('');
|
||||||
toast.success(`Added ${contactInput}`);
|
toast.success(`Added ${contactInput}`);
|
||||||
};
|
};
|
||||||
@@ -407,16 +443,16 @@ export default function App() {
|
|||||||
const handleRemoveContact = (contact) => {
|
const handleRemoveContact = (contact) => {
|
||||||
const newContacts = contacts.filter(c => c !== contact);
|
const newContacts = contacts.filter(c => c !== contact);
|
||||||
setContacts(newContacts);
|
setContacts(newContacts);
|
||||||
localStorage.setItem('rondevu-chat-contacts', JSON.stringify(newContacts));
|
localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts));
|
||||||
if (selectedChat === contact) {
|
if (selectedChat === contact) {
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
}
|
}
|
||||||
toast.success(`Removed ${contact}`);
|
toast.success(`Removed ${contact}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start chat with contact using durable connection
|
// Start chat with contact (answerer role)
|
||||||
const handleStartChat = async (contact) => {
|
const handleStartChat = async (contact) => {
|
||||||
if (!client || activeChats[contact]?.status === 'connected') {
|
if (!rondevu || activeChats[contact]?.status === 'connected') {
|
||||||
setSelectedChat(contact);
|
setSelectedChat(contact);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -424,76 +460,161 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
toast.loading(`Connecting to ${contact}...`, { id: 'connecting' });
|
toast.loading(`Connecting to ${contact}...`, { id: 'connecting' });
|
||||||
|
|
||||||
// Create durable connection
|
// Discover peer's service
|
||||||
const connection = await client.connect(contact, 'chat.rondevu@1.0.0', {
|
const fqn = `${CHAT_SERVICE}@${contact}`;
|
||||||
rtcConfig: getCurrentRtcConfig(),
|
const serviceData = await rondevu.getService(fqn);
|
||||||
maxReconnectAttempts: 5
|
|
||||||
|
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 data channel (must match service pool's channel name)
|
// Create answer
|
||||||
const channel = connection.createChannel('rondevu-service');
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
// Listen for connection events
|
// Send answer
|
||||||
connection.on('connected', () => {
|
await rondevu.postOfferAnswer(fqn, serviceData.offerId, answer.sdp);
|
||||||
console.log(`✅ Connected to ${contact}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.on('reconnecting', (attempt, max, delay) => {
|
// Poll for ICE candidates
|
||||||
console.log(`🔄 Reconnecting to ${contact} (${attempt}/${max}) in ${delay}ms`);
|
const lastIceTimestamp = { current: 0 };
|
||||||
toast.loading(`Reconnecting to ${contact}...`, { id: 'reconnecting' });
|
const icePolling = setInterval(async () => {
|
||||||
});
|
try {
|
||||||
|
const result = await rondevu.getOfferIceCandidates(
|
||||||
|
fqn,
|
||||||
|
serviceData.offerId,
|
||||||
|
lastIceTimestamp.current
|
||||||
|
);
|
||||||
|
|
||||||
connection.on('disconnected', () => {
|
for (const item of result.candidates) {
|
||||||
console.log(`🔌 Disconnected from ${contact}`);
|
if (item.candidate && item.candidate.candidate) {
|
||||||
setActiveChats(prev => ({
|
try {
|
||||||
...prev,
|
const rtcCandidate = new RTCIceCandidate(item.candidate);
|
||||||
[contact]: { ...prev[contact], status: 'reconnecting' }
|
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) {
|
||||||
|
if (err.message?.includes('404') || err.message?.includes('410')) {
|
||||||
|
console.warn('Offer expired, stopping ICE polling');
|
||||||
|
clearInterval(icePolling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
connection.on('failed', (error) => {
|
// Send local ICE candidates
|
||||||
console.error(`❌ Connection to ${contact} failed:`, error);
|
pc.onicecandidate = (event) => {
|
||||||
toast.error(`Connection to ${contact} failed`, { id: 'connecting' });
|
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 => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[contact]: { ...prev[contact], status: 'disconnected' }
|
[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' });
|
|
||||||
|
|
||||||
|
// Store connection info
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[contact]: {
|
[contact]: {
|
||||||
username: contact,
|
username: contact,
|
||||||
channel,
|
connection: pc,
|
||||||
connection,
|
channel: dataChannel, // Will be set when ondatachannel fires
|
||||||
messages: prev[contact]?.messages || [],
|
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'
|
status: 'connected'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
setSelectedChat(contact);
|
toast.success(`Connected to ${contact}`, { id: 'connecting' });
|
||||||
|
} else if (msg.type === 'message') {
|
||||||
// Update handler for chat messages
|
// Chat message
|
||||||
channel.off('message');
|
|
||||||
channel.on('message', (chatData) => {
|
|
||||||
try {
|
|
||||||
const chatMsg = JSON.parse(chatData);
|
|
||||||
if (chatMsg.type === 'message') {
|
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[contact]: {
|
[contact]: {
|
||||||
...prev[contact],
|
...prev[contact],
|
||||||
messages: [...(prev[contact]?.messages || []), {
|
messages: [...(prev[contact]?.messages || []), {
|
||||||
from: contact,
|
from: contact,
|
||||||
text: chatMsg.text,
|
text: msg.text,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
@@ -502,33 +623,16 @@ export default function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse message:', err);
|
console.error('Failed to parse message:', err);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse ack:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
channel.on('close', () => {
|
// Update the channel reference in state
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[contact]: { ...prev[contact], status: 'disconnected' }
|
[contact]: {
|
||||||
}));
|
...prev[contact],
|
||||||
toast.error(`Disconnected from ${contact}`);
|
channel: dc
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
// Send message
|
||||||
@@ -575,7 +679,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!client) {
|
if (!rondevu) {
|
||||||
return <div style={styles.loading}>Loading...</div>;
|
return <div style={styles.loading}>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,9 +755,9 @@ export default function App() {
|
|||||||
<h1 style={styles.setupTitle}>Rondevu Chat</h1>
|
<h1 style={styles.setupTitle}>Rondevu Chat</h1>
|
||||||
<p style={styles.setupSubtitle}>Decentralized P2P Chat</p>
|
<p style={styles.setupSubtitle}>Decentralized P2P Chat</p>
|
||||||
|
|
||||||
{setupStep === 'register' && (
|
{setupStep === 'init' && (
|
||||||
<div>
|
<div>
|
||||||
<p style={styles.setupDesc}>Registering...</p>
|
<p style={styles.setupDesc}>Initializing...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user