// --------------------------------------------------------------------------- // ConnectedUsers // // Tiny presence indicator for the editor top bar - shows the avatars of // every OTHER user currently editing the document, overlapping pill // style. The local user isn't included (they already see their own // chip on the far right). // // Data source: the Hocuspocus provider's Y.js awareness, populated by // CollaborationCursorV3 with `{ name, color, avatarUrl }` per client. // We re-read on every awareness "change" event so joins/leaves / // rename flips update within a tick. // // The provider is created lazily by and assigned to // providerRef AFTER this component mounts, so we use the same // "poll until ref populates, then attach listener" pattern as // SyncIndicator (which had the exact same latent bug). Without that, // the effect would early-return on the first run and never re-fire. // --------------------------------------------------------------------------- import { useEffect, useState } from "react"; import type { HocuspocusProvider } from "@hocuspocus/provider"; import { Tooltip } from "./Tooltip"; /** * One unique person currently editing, after collapsing every awareness * client that shares the same identity. `count` is how many live * connections (tabs / devices / lingering pre-timeout sockets) that person * has - surfaced as a small badge instead of repeating the same avatar. */ interface ConnectedUser { key: string; clientId: number; name: string; color: string; avatarUrl?: string; count: number; } /** * Stable identity key for deduplication. The HF username (`name`) is unique * per account, so it's the primary key; we fall back to the avatar URL and * finally the clientId so anonymous / malformed states still render once. */ function identityKey(name: string, avatarUrl?: string, clientId?: number): string { const n = name.trim().toLowerCase(); if (n) return `name:${n}`; if (avatarUrl) return `avatar:${avatarUrl}`; return `client:${clientId ?? "?"}`; } interface Props { providerRef: { current: HocuspocusProvider | null }; } /** Max avatars shown inline before collapsing the tail into a "+N" badge. */ const MAX_VISIBLE = 4; export function ConnectedUsers({ providerRef }: Props) { const [users, setUsers] = useState([]); useEffect(() => { let cleanup: (() => void) | null = null; let pollId: ReturnType | null = null; const attach = (provider: HocuspocusProvider) => { const awareness = (provider as any).awareness; if (!awareness) return () => {}; const sync = () => { const localClientId = awareness.clientID; // First pass: one entry per awareness client (raw connections). const raw: Omit[] = []; awareness.getStates().forEach((state: any, clientId: number) => { if (clientId === localClientId) return; const user = state?.user; if (!user || !user.name) return; raw.push({ clientId, name: String(user.name), color: String(user.color || "#888"), avatarUrl: user.avatarUrl ? String(user.avatarUrl) : undefined, }); }); // Stable order: by clientId so the avatars don't dance around // every time someone moves their cursor and emits an update. raw.sort((a, b) => a.clientId - b.clientId); // Second pass: collapse same-identity clients into one avatar. // The same person opening two tabs - or a stale pre-timeout socket // lingering next to a fresh reconnect - used to render the exact // same circle N times. Now we show it once with a "xN" badge. const byKey = new Map(); for (const u of raw) { const key = identityKey(u.name, u.avatarUrl, u.clientId); const existing = byKey.get(key); if (existing) { existing.count += 1; } else { byKey.set(key, { ...u, key, count: 1 }); } } setUsers([...byKey.values()]); }; sync(); awareness.on("change", sync); return () => awareness.off("change", sync); }; if (providerRef.current) { cleanup = attach(providerRef.current); } else { pollId = setInterval(() => { const p = providerRef.current; if (!p) return; if (pollId) { clearInterval(pollId); pollId = null; } cleanup = attach(p); }, 100); } return () => { if (pollId) clearInterval(pollId); if (cleanup) cleanup(); }; }, [providerRef]); if (users.length === 0) return null; const visible = users.slice(0, MAX_VISIBLE); const overflow = users.length - visible.length; const totalLabel = `${users.length} other ${users.length === 1 ? "user" : "users"} editing`; return (
{visible.map((u) => ( 1 ? `${u.name} (${u.count} open sessions)` : u.name} > {u.avatarUrl ? ( ) : ( {u.name.slice(0, 1).toUpperCase()} )} {u.count > 1 && ( )} ))} {overflow > 0 && ( u.name) .join(", ")} > +{overflow} )}
); }