tfrere HF Staff Cursor commited on
Commit
a286ca4
·
1 Parent(s): 1f88239

feat(editor): dedupe connected collaborators by identity

Browse files

The presence avatars were keyed by Yjs awareness clientId, so the same
person showed up once per connection: every extra tab/device, and every
lingering pre-timeout socket left behind by a reconnect (amplified by the
refocus re-sync), rendered the same circle again.

Collapse awareness clients sharing an identity (HF username, falling back to
avatar URL then clientId) into a single avatar, with a "xN" badge + tooltip
when a person has multiple live sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/src/components/ConnectedUsers.tsx CHANGED
@@ -22,11 +22,31 @@ import { useEffect, useState } from "react";
22
  import type { HocuspocusProvider } from "@hocuspocus/provider";
23
  import { Tooltip } from "./Tooltip";
24
 
25
- interface AwarenessUser {
 
 
 
 
 
 
 
26
  clientId: number;
27
  name: string;
28
  color: string;
29
  avatarUrl?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
 
32
  interface Props {
@@ -37,7 +57,7 @@ interface Props {
37
  const MAX_VISIBLE = 4;
38
 
39
  export function ConnectedUsers({ providerRef }: Props) {
40
- const [users, setUsers] = useState<AwarenessUser[]>([]);
41
 
42
  useEffect(() => {
43
  let cleanup: (() => void) | null = null;
@@ -49,12 +69,14 @@ export function ConnectedUsers({ providerRef }: Props) {
49
 
50
  const sync = () => {
51
  const localClientId = awareness.clientID;
52
- const arr: AwarenessUser[] = [];
 
 
53
  awareness.getStates().forEach((state: any, clientId: number) => {
54
  if (clientId === localClientId) return;
55
  const user = state?.user;
56
  if (!user || !user.name) return;
57
- arr.push({
58
  clientId,
59
  name: String(user.name),
60
  color: String(user.color || "#888"),
@@ -63,8 +85,23 @@ export function ConnectedUsers({ providerRef }: Props) {
63
  });
64
  // Stable order: by clientId so the avatars don't dance around
65
  // every time someone moves their cursor and emits an update.
66
- arr.sort((a, b) => a.clientId - b.clientId);
67
- setUsers(arr);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  };
69
 
70
  sync();
@@ -101,7 +138,10 @@ export function ConnectedUsers({ providerRef }: Props) {
101
  return (
102
  <div className="connected-users" aria-label={totalLabel}>
103
  {visible.map((u) => (
104
- <Tooltip key={u.clientId} title={u.name}>
 
 
 
105
  <span
106
  className="connected-users__avatar"
107
  style={{ borderColor: u.color }}
@@ -116,6 +156,11 @@ export function ConnectedUsers({ providerRef }: Props) {
116
  {u.name.slice(0, 1).toUpperCase()}
117
  </span>
118
  )}
 
 
 
 
 
119
  </span>
120
  </Tooltip>
121
  ))}
 
22
  import type { HocuspocusProvider } from "@hocuspocus/provider";
23
  import { Tooltip } from "./Tooltip";
24
 
25
+ /**
26
+ * One unique person currently editing, after collapsing every awareness
27
+ * client that shares the same identity. `count` is how many live
28
+ * connections (tabs / devices / lingering pre-timeout sockets) that person
29
+ * has - surfaced as a small badge instead of repeating the same avatar.
30
+ */
31
+ interface ConnectedUser {
32
+ key: string;
33
  clientId: number;
34
  name: string;
35
  color: string;
36
  avatarUrl?: string;
37
+ count: number;
38
+ }
39
+
40
+ /**
41
+ * Stable identity key for deduplication. The HF username (`name`) is unique
42
+ * per account, so it's the primary key; we fall back to the avatar URL and
43
+ * finally the clientId so anonymous / malformed states still render once.
44
+ */
45
+ function identityKey(name: string, avatarUrl?: string, clientId?: number): string {
46
+ const n = name.trim().toLowerCase();
47
+ if (n) return `name:${n}`;
48
+ if (avatarUrl) return `avatar:${avatarUrl}`;
49
+ return `client:${clientId ?? "?"}`;
50
  }
51
 
52
  interface Props {
 
57
  const MAX_VISIBLE = 4;
58
 
59
  export function ConnectedUsers({ providerRef }: Props) {
60
+ const [users, setUsers] = useState<ConnectedUser[]>([]);
61
 
62
  useEffect(() => {
63
  let cleanup: (() => void) | null = null;
 
69
 
70
  const sync = () => {
71
  const localClientId = awareness.clientID;
72
+
73
+ // First pass: one entry per awareness client (raw connections).
74
+ const raw: Omit<ConnectedUser, "key" | "count">[] = [];
75
  awareness.getStates().forEach((state: any, clientId: number) => {
76
  if (clientId === localClientId) return;
77
  const user = state?.user;
78
  if (!user || !user.name) return;
79
+ raw.push({
80
  clientId,
81
  name: String(user.name),
82
  color: String(user.color || "#888"),
 
85
  });
86
  // Stable order: by clientId so the avatars don't dance around
87
  // every time someone moves their cursor and emits an update.
88
+ raw.sort((a, b) => a.clientId - b.clientId);
89
+
90
+ // Second pass: collapse same-identity clients into one avatar.
91
+ // The same person opening two tabs - or a stale pre-timeout socket
92
+ // lingering next to a fresh reconnect - used to render the exact
93
+ // same circle N times. Now we show it once with a "xN" badge.
94
+ const byKey = new Map<string, ConnectedUser>();
95
+ for (const u of raw) {
96
+ const key = identityKey(u.name, u.avatarUrl, u.clientId);
97
+ const existing = byKey.get(key);
98
+ if (existing) {
99
+ existing.count += 1;
100
+ } else {
101
+ byKey.set(key, { ...u, key, count: 1 });
102
+ }
103
+ }
104
+ setUsers([...byKey.values()]);
105
  };
106
 
107
  sync();
 
138
  return (
139
  <div className="connected-users" aria-label={totalLabel}>
140
  {visible.map((u) => (
141
+ <Tooltip
142
+ key={u.key}
143
+ title={u.count > 1 ? `${u.name} (${u.count} open sessions)` : u.name}
144
+ >
145
  <span
146
  className="connected-users__avatar"
147
  style={{ borderColor: u.color }}
 
156
  {u.name.slice(0, 1).toUpperCase()}
157
  </span>
158
  )}
159
+ {u.count > 1 && (
160
+ <span className="connected-users__count" aria-hidden="true">
161
+ {u.count}
162
+ </span>
163
+ )}
164
  </span>
165
  </Tooltip>
166
  ))}
frontend/src/styles/components/_connected-users.css CHANGED
@@ -21,7 +21,8 @@
21
  width: 24px;
22
  height: 24px;
23
  border-radius: 50%;
24
- overflow: hidden;
 
25
  border: 2px solid var(--border-color);
26
  background: var(--surface-bg);
27
  margin-left: -6px;
@@ -33,6 +34,11 @@
33
  }
34
  .connected-users__avatar:hover {
35
  transform: translateY(-1px) scale(1.08);
 
 
 
 
 
36
  z-index: 2;
37
  }
38
 
@@ -41,6 +47,7 @@
41
  height: 100%;
42
  object-fit: cover;
43
  display: block;
 
44
  }
45
 
46
  .connected-users__initials {
@@ -54,6 +61,28 @@
54
  color: #fff;
55
  text-transform: uppercase;
56
  line-height: 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
 
59
  .connected-users__more {
 
21
  width: 24px;
22
  height: 24px;
23
  border-radius: 50%;
24
+ /* No overflow:hidden here: the circular clip lives on the img/initials so
25
+ the count badge can sit on top of (and slightly outside) the avatar. */
26
  border: 2px solid var(--border-color);
27
  background: var(--surface-bg);
28
  margin-left: -6px;
 
34
  }
35
  .connected-users__avatar:hover {
36
  transform: translateY(-1px) scale(1.08);
37
+ z-index: 3;
38
+ }
39
+ /* Keep badged avatars above their right-hand neighbour so the count
40
+ (which sits on the right edge, where avatars overlap) stays visible. */
41
+ .connected-users__avatar:has(> .connected-users__count) {
42
  z-index: 2;
43
  }
44
 
 
47
  height: 100%;
48
  object-fit: cover;
49
  display: block;
50
+ border-radius: 50%;
51
  }
52
 
53
  .connected-users__initials {
 
61
  color: #fff;
62
  text-transform: uppercase;
63
  line-height: 1;
64
+ border-radius: 50%;
65
+ }
66
+
67
+ .connected-users__count {
68
+ position: absolute;
69
+ bottom: -3px;
70
+ right: -4px;
71
+ min-width: 14px;
72
+ height: 14px;
73
+ padding: 0 3px;
74
+ box-sizing: border-box;
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ border-radius: 7px;
79
+ background: var(--primary-color);
80
+ color: var(--on-primary, #fff);
81
+ font-size: 9px;
82
+ font-weight: 700;
83
+ line-height: 1;
84
+ border: 1.5px solid var(--surface-bg);
85
+ font-variant-numeric: tabular-nums;
86
  }
87
 
88
  .connected-users__more {