Spaces:
Runtime error
Runtime error
| // --------------------------------------------------------------------------- | |
| // 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 <Editor> 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<ConnectedUser[]>([]); | |
| useEffect(() => { | |
| let cleanup: (() => void) | null = null; | |
| let pollId: ReturnType<typeof setInterval> | 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<ConnectedUser, "key" | "count">[] = []; | |
| 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<string, ConnectedUser>(); | |
| 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 ( | |
| <div className="connected-users" aria-label={totalLabel}> | |
| {visible.map((u) => ( | |
| <Tooltip | |
| key={u.key} | |
| title={u.count > 1 ? `${u.name} (${u.count} open sessions)` : u.name} | |
| > | |
| <span | |
| className={`connected-users__avatar${u.count > 1 ? " connected-users__avatar--multi" : ""}`} | |
| style={{ borderColor: u.color }} | |
| > | |
| {u.avatarUrl ? ( | |
| <img src={u.avatarUrl} alt="" referrerPolicy="no-referrer" /> | |
| ) : ( | |
| <span | |
| className="connected-users__initials" | |
| style={{ background: u.color }} | |
| > | |
| {u.name.slice(0, 1).toUpperCase()} | |
| </span> | |
| )} | |
| {u.count > 1 && ( | |
| <span className="connected-users__count" aria-hidden="true"> | |
| {u.count} | |
| </span> | |
| )} | |
| </span> | |
| </Tooltip> | |
| ))} | |
| {overflow > 0 && ( | |
| <Tooltip | |
| title={users | |
| .slice(MAX_VISIBLE) | |
| .map((u) => u.name) | |
| .join(", ")} | |
| > | |
| <span className="connected-users__more">+{overflow}</span> | |
| </Tooltip> | |
| )} | |
| </div> | |
| ); | |
| } | |