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:
parent
4a7c6e6650
commit
6f68fcd4ae
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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') => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue