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 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
|
||||
})
|
||||
|
|
@ -436,6 +439,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) {
|
||||
console.log('🔌 CloudflareAdapter: Received sync message with data:', {
|
||||
|
|
@ -650,6 +662,18 @@ export class CloudflareNetworkAdapter extends NetworkAdapter {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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') => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue