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>
  );
}