diff --git a/shared/collab-presence.ts b/shared/collab-presence.ts index 975859fb..f1f12058 100644 --- a/shared/collab-presence.ts +++ b/shared/collab-presence.ts @@ -57,6 +57,7 @@ export function broadcastPresence(opts: PresenceOpts): void { noteId: opts.noteId, itemId: opts.itemId, username: session.username, + userId: session.userId, color: userColor(session.userId), }); } @@ -75,21 +76,17 @@ export function broadcastLeave(): void { } /** - * Start a 10-second heartbeat that broadcasts presence. + * Start a 5-second heartbeat that broadcasts presence. * Returns a cleanup function to stop the heartbeat. * + * No beforeunload leave — navigating between rApps in the same space would + * otherwise flash the user offline to peers. Peers GC us after ~12s if we + * truly close the tab. + * * @param getOpts - Called each heartbeat to get current presence state */ export function startPresenceHeartbeat(getOpts: () => PresenceOpts): () => void { broadcastPresence(getOpts()); - const timer = setInterval(() => broadcastPresence(getOpts()), 10_000); - - // Clean up immediately on page unload - const onUnload = () => broadcastLeave(); - window.addEventListener('beforeunload', onUnload); - - return () => { - clearInterval(timer); - window.removeEventListener('beforeunload', onUnload); - }; + const timer = setInterval(() => broadcastPresence(getOpts()), 5_000); + return () => clearInterval(timer); } diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts index 97e4f0bd..2ccb6fb9 100644 --- a/shared/components/rstack-collab-overlay.ts +++ b/shared/components/rstack-collab-overlay.ts @@ -27,6 +27,7 @@ const PEER_COLORS = [ interface PeerState { peerId: string; + userId?: string; // stable identity across page nav / peerId churn username: string; color: string; cursor: { x: number; y: number } | null; @@ -242,16 +243,25 @@ export class RStackCollabOverlay extends HTMLElement { this.#connectToDoc(); } - // Listen for space-wide presence broadcasts (module context from all rApps) + // Listen for space-wide presence broadcasts (module context from all rApps). + // Key by userId when available so a peer navigating between rApps (new WS peerId + // each page) maps back to the same row instead of flashing offline. this.#unsubPresence = runtime.onCustomMessage('presence', (msg: any) => { if (!msg.username) return; - // Use peerId from server relay, or fall back to a hash of username const pid = msg.peerId || `anon-${msg.username}`; if (pid === this.#localPeerId) return; // ignore self - const existing = this.#peers.get(pid); - this.#peers.set(pid, { + const key = msg.userId || pid; + // Drop any stale entry keyed by the old peerId for this same userId + if (msg.userId) { + for (const [k, p] of this.#peers) { + if (k !== key && p.userId === msg.userId) this.#peers.delete(k); + } + } + const existing = this.#peers.get(key); + this.#peers.set(key, { peerId: pid, + userId: msg.userId || existing?.userId, username: msg.username || existing?.username || 'Anonymous', color: msg.color || existing?.color || this.#colorForPeer(pid), cursor: existing?.cursor ?? null, @@ -499,7 +509,7 @@ export class RStackCollabOverlay extends HTMLElement { #gcPeers() { const now = Date.now(); - const staleThreshold = this.#externalPeers ? 30000 : 15000; + const staleThreshold = this.#externalPeers ? 30000 : 12000; let changed = false; for (const [id, peer] of this.#peers) { if (now - peer.lastSeen > staleThreshold) { @@ -522,13 +532,27 @@ export class RStackCollabOverlay extends HTMLElement { } } - /** Deduplicate peers by username, keeping the most recently seen entry per user. */ + /** + * Deduplicate peers for display. Keyed by username (lowercase) so a user + * appearing twice — e.g. old awareness entry (peerId-keyed) alongside a new + * presence entry (userId-keyed) after navigating rApps — collapses to one + * row. Prefers entries carrying module/context metadata so the panel shows + * which rApp the peer is in. + */ #uniquePeers(): PeerState[] { const byName = new Map(); for (const peer of this.#peers.values()) { const key = peer.username.toLowerCase(); const existing = byName.get(key); - if (!existing || peer.lastSeen > existing.lastSeen) { + if (!existing) { + byName.set(key, peer); + continue; + } + const peerHasMeta = !!(peer.module || peer.context); + const existingHasMeta = !!(existing.module || existing.context); + if (peerHasMeta && !existingHasMeta) { + byName.set(key, peer); + } else if (peerHasMeta === existingHasMeta && peer.lastSeen > existing.lastSeen) { byName.set(key, peer); } }