File size: 6,141 Bytes
aa09c01 a286ca4 aa09c01 a286ca4 aa09c01 a286ca4 aa09c01 a286ca4 aa09c01 a286ca4 aa09c01 a286ca4 aa09c01 a286ca4 aa09c01 a286ca4 aa09c01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | // ---------------------------------------------------------------------------
// 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>
);
}
|