diff --git a/src/automerge/CloudflareAdapter.ts b/src/automerge/CloudflareAdapter.ts index 746115a..209a109 100644 --- a/src/automerge/CloudflareAdapter.ts +++ b/src/automerge/CloudflareAdapter.ts @@ -182,6 +182,7 @@ export class CloudflareNetworkAdapter extends NetworkAdapter { private isConnecting: boolean = false private onJsonSyncData?: (data: any) => void private onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void + private onPresenceLeave?: (sessionId: string) => void // Binary sync mode - when true, uses native Automerge sync protocol private useBinarySync: boolean = true @@ -221,13 +222,15 @@ export class CloudflareNetworkAdapter extends NetworkAdapter { workerUrl: string, roomId?: string, onJsonSyncData?: (data: any) => void, - onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void + onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => void, + onPresenceLeave?: (sessionId: string) => void ) { super() this.workerUrl = workerUrl this.roomId = roomId || 'default-room' this.onJsonSyncData = onJsonSyncData this.onPresenceUpdate = onPresenceUpdate + this.onPresenceLeave = onPresenceLeave this.readyPromise = new Promise((resolve) => { this.readyResolve = resolve }) @@ -435,6 +438,15 @@ export class CloudflareNetworkAdapter extends NetworkAdapter { } return } + + // Handle leave messages (user disconnected) + if (message.type === 'leave') { + console.log('👋 CloudflareAdapter: User left:', message.sessionId) + if (this.onPresenceLeave && message.sessionId) { + this.onPresenceLeave(message.sessionId) + } + return + } // Convert the message to the format expected by Automerge if (message.type === 'sync' && message.data) { @@ -648,8 +660,20 @@ export class CloudflareNetworkAdapter extends NetworkAdapter { private cleanup(): void { this.stopKeepAlive() this.clearReconnectTimeout() - + if (this.websocket) { + // Send leave message before closing to notify other clients + if (this.websocket.readyState === WebSocket.OPEN && this.sessionId) { + try { + this.websocket.send(JSON.stringify({ + type: 'leave', + sessionId: this.sessionId + })) + console.log('👋 CloudflareAdapter: Sent leave message for session:', this.sessionId) + } catch (e) { + // Ignore errors when sending leave message + } + } this.websocket.close(1000, 'Client disconnecting') this.websocket = null } diff --git a/src/components/networking/NetworkGraphMinimap.tsx b/src/components/networking/NetworkGraphMinimap.tsx index 835b7cd..88cff88 100644 --- a/src/components/networking/NetworkGraphMinimap.tsx +++ b/src/components/networking/NetworkGraphMinimap.tsx @@ -318,10 +318,11 @@ export function NetworkGraphMinimap({ .style('cursor', 'pointer') .on('mouseenter', (event, d) => { const rect = svgRef.current!.getBoundingClientRect(); + const name = d.displayName || d.username; setTooltip({ x: event.clientX - rect.left, y: event.clientY - rect.top, - text: d.displayName || d.username, + text: d.isCurrentUser ? `${name} (you)` : name, }); }) .on('mouseleave', () => { diff --git a/src/components/networking/useNetworkGraph.ts b/src/components/networking/useNetworkGraph.ts index 304746c..7cbc085 100644 --- a/src/components/networking/useNetworkGraph.ts +++ b/src/components/networking/useNetworkGraph.ts @@ -242,6 +242,27 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor isAnonymous: false, // Nodes from the graph are authenticated })); + // Always ensure the current user is in the graph, even if they have no connections + const currentUserInGraph = enrichedNodes.some(n => n.isCurrentUser); + if (!currentUserInGraph) { + // Find current user in room participants + const currentUserParticipant = roomParticipants.find(p => p.id === session.username); + if (currentUserParticipant) { + enrichedNodes.push({ + id: currentUserParticipant.id, + username: currentUserParticipant.username, + displayName: currentUserParticipant.username, + avatarColor: currentUserParticipant.color, + isInRoom: true, + roomPresenceColor: currentUserParticipant.color, + isCurrentUser: true, + isAnonymous: false, + trustLevelTo: undefined, + trustLevelFrom: undefined, + }); + } + } + // Add room participants who are not in the network graph as anonymous nodes roomParticipants.forEach(participant => { if (!graphNodeIds.has(participant.id) && participant.id !== session.username) { @@ -297,17 +318,52 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor } }, [refreshInterval, fetchGraph]); - // Update room status when participants change + // Update room status when participants change AND add new participants immediately useEffect(() => { - setState(prev => ({ - ...prev, - nodes: prev.nodes.map(node => ({ + setState(prev => { + const existingNodeIds = new Set(prev.nodes.map(n => n.id)); + + // Update existing nodes with room status + const updatedNodes = prev.nodes.map(node => ({ ...node, isInRoom: participantIds.includes(node.id), roomPresenceColor: participantColorMap.get(node.id), - })), - })); - }, [participantIds, participantColorMap]); + })); + + // Add any new room participants that aren't in the graph yet + roomParticipants.forEach(participant => { + if (!existingNodeIds.has(participant.id)) { + // Check if this is the current user + const isCurrentUser = participant.id === session.username; + + // Check if this looks like an anonymous/guest ID + const isAnonymous = !isCurrentUser && ( + participant.username.startsWith('Guest') || + participant.username === 'Anonymous' || + !participant.id.match(/^[a-zA-Z0-9_-]+$/) + ); + + updatedNodes.push({ + id: participant.id, + username: participant.username, + displayName: participant.username, + avatarColor: participant.color, + isInRoom: true, + roomPresenceColor: participant.color, + isCurrentUser, + isAnonymous, + trustLevelTo: undefined, + trustLevelFrom: undefined, + }); + } + }); + + return { + ...prev, + nodes: updatedNodes, + }; + }); + }, [participantIds, participantColorMap, roomParticipants, session.username]); // Connect to a user const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => { diff --git a/worker/AutomergeDurableObject.ts b/worker/AutomergeDurableObject.ts index 48f8804..6064f6c 100644 --- a/worker/AutomergeDurableObject.ts +++ b/worker/AutomergeDurableObject.ts @@ -427,6 +427,13 @@ export class AutomergeDurableObject { serverWebSocket.addEventListener("close", (event) => { console.log(`🔌 AutomergeDurableObject: Client disconnected: ${sessionId}, code: ${event.code}, reason: ${event.reason}`) this.clients.delete(sessionId) + + // Broadcast leave message to all other clients so they can remove presence + this.broadcastToOthers(sessionId, { + type: 'leave', + sessionId: sessionId + }) + // Clean up sync manager state for this peer and flush pending saves if (this.syncManager) { this.syncManager.handlePeerDisconnect(sessionId).catch((error) => { @@ -610,6 +617,15 @@ export class AutomergeDurableObject { } this.broadcastToOthers(sessionId, presenceMessage) break + case "leave": + // Handle explicit leave message (client is about to disconnect) + // Broadcast to all other clients so they can remove presence + console.log(`👋 Received leave message from ${sessionId}`) + this.broadcastToOthers(sessionId, { + type: 'leave', + sessionId: message.sessionId || sessionId + }) + break default: console.log("Unknown message type:", message.type) }