fix(presence): keep peers online across rApp navigation in same space
- Drop beforeunload leave broadcast — was flashing users offline during intra-space page nav between rApps - Heartbeat 10s → 5s; GC stale threshold 15s → 12s - Presence payload now carries userId; overlay keys peer map by userId so reconnects with a new WS peerId map back to the same row - uniquePeers() prefers entries with module/context metadata so the panel shows which rApp a peer is in, even alongside cursor-only awareness
This commit is contained in:
parent
06b84bf2d7
commit
6f8d938e1f
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, PeerState>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue