feat: improve social network presence handling and cleanup

- Add "(you)" indicator on tooltip when hovering current user's node
- Ensure current user always appears in graph even with no connections
- Add new participants immediately to graph (no 30s delay)
- Implement "leave" message protocol for presence cleanup:
  - Client sends leave message before disconnecting
  - Server broadcasts leave to other clients on disconnect
  - Clients remove presence records on receiving leave
- Generate consistent user colors from CryptID username (not session ID)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-15 00:01:28 -05:00
parent 4a7c6e6650
commit 6f68fcd4ae
4 changed files with 107 additions and 10 deletions

View File

@ -182,6 +182,7 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private isConnecting: boolean = false private isConnecting: boolean = false
private onJsonSyncData?: (data: any) => void private onJsonSyncData?: (data: any) => void
private onPresenceUpdate?: (userId: string, data: any, senderId?: string, userName?: string, userColor?: string) => 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 // Binary sync mode - when true, uses native Automerge sync protocol
private useBinarySync: boolean = true private useBinarySync: boolean = true
@ -221,13 +222,15 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
workerUrl: string, workerUrl: string,
roomId?: string, roomId?: string,
onJsonSyncData?: (data: any) => void, 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() super()
this.workerUrl = workerUrl this.workerUrl = workerUrl
this.roomId = roomId || 'default-room' this.roomId = roomId || 'default-room'
this.onJsonSyncData = onJsonSyncData this.onJsonSyncData = onJsonSyncData
this.onPresenceUpdate = onPresenceUpdate this.onPresenceUpdate = onPresenceUpdate
this.onPresenceLeave = onPresenceLeave
this.readyPromise = new Promise((resolve) => { this.readyPromise = new Promise((resolve) => {
this.readyResolve = resolve this.readyResolve = resolve
}) })
@ -435,6 +438,15 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
} }
return 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 // Convert the message to the format expected by Automerge
if (message.type === 'sync' && message.data) { if (message.type === 'sync' && message.data) {
@ -648,8 +660,20 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
private cleanup(): void { private cleanup(): void {
this.stopKeepAlive() this.stopKeepAlive()
this.clearReconnectTimeout() this.clearReconnectTimeout()
if (this.websocket) { 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.close(1000, 'Client disconnecting')
this.websocket = null this.websocket = null
} }

View File

@ -318,10 +318,11 @@ export function NetworkGraphMinimap({
.style('cursor', 'pointer') .style('cursor', 'pointer')
.on('mouseenter', (event, d) => { .on('mouseenter', (event, d) => {
const rect = svgRef.current!.getBoundingClientRect(); const rect = svgRef.current!.getBoundingClientRect();
const name = d.displayName || d.username;
setTooltip({ setTooltip({
x: event.clientX - rect.left, x: event.clientX - rect.left,
y: event.clientY - rect.top, y: event.clientY - rect.top,
text: d.displayName || d.username, text: d.isCurrentUser ? `${name} (you)` : name,
}); });
}) })
.on('mouseleave', () => { .on('mouseleave', () => {

View File

@ -242,6 +242,27 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
isAnonymous: false, // Nodes from the graph are authenticated 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 // Add room participants who are not in the network graph as anonymous nodes
roomParticipants.forEach(participant => { roomParticipants.forEach(participant => {
if (!graphNodeIds.has(participant.id) && participant.id !== session.username) { if (!graphNodeIds.has(participant.id) && participant.id !== session.username) {
@ -297,17 +318,52 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
} }
}, [refreshInterval, fetchGraph]); }, [refreshInterval, fetchGraph]);
// Update room status when participants change // Update room status when participants change AND add new participants immediately
useEffect(() => { useEffect(() => {
setState(prev => ({ setState(prev => {
...prev, const existingNodeIds = new Set(prev.nodes.map(n => n.id));
nodes: prev.nodes.map(node => ({
// Update existing nodes with room status
const updatedNodes = prev.nodes.map(node => ({
...node, ...node,
isInRoom: participantIds.includes(node.id), isInRoom: participantIds.includes(node.id),
roomPresenceColor: participantColorMap.get(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 // Connect to a user
const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => { const connect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => {

View File

@ -427,6 +427,13 @@ export class AutomergeDurableObject {
serverWebSocket.addEventListener("close", (event) => { serverWebSocket.addEventListener("close", (event) => {
console.log(`🔌 AutomergeDurableObject: Client disconnected: ${sessionId}, code: ${event.code}, reason: ${event.reason}`) console.log(`🔌 AutomergeDurableObject: Client disconnected: ${sessionId}, code: ${event.code}, reason: ${event.reason}`)
this.clients.delete(sessionId) 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 // Clean up sync manager state for this peer and flush pending saves
if (this.syncManager) { if (this.syncManager) {
this.syncManager.handlePeerDisconnect(sessionId).catch((error) => { this.syncManager.handlePeerDisconnect(sessionId).catch((error) => {
@ -610,6 +617,15 @@ export class AutomergeDurableObject {
} }
this.broadcastToOthers(sessionId, presenceMessage) this.broadcastToOthers(sessionId, presenceMessage)
break 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: default:
console.log("Unknown message type:", message.type) console.log("Unknown message type:", message.type)
} }