carbon-tokenization / frontend /src /components /ConnectedUsers.tsx
tfrere's picture
tfrere HF Staff
feat(editor): dedupe connected collaborators by identity
a286ca4
// ---------------------------------------------------------------------------
// 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"
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>
);
}