| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useState } from "react"; |
| import type { HocuspocusProvider } from "@hocuspocus/provider"; |
| import { Tooltip } from "./Tooltip"; |
|
|
| |
| |
| |
| |
| |
| |
| interface ConnectedUser { |
| key: string; |
| clientId: number; |
| name: string; |
| color: string; |
| avatarUrl?: string; |
| count: number; |
| } |
|
|
| |
| |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| 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; |
|
|
| |
| 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, |
| }); |
| }); |
| |
| |
| raw.sort((a, b) => a.clientId - b.clientId); |
|
|
| |
| |
| |
| |
| 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> |
| ); |
| } |
|
|