feat(editor): dedupe connected collaborators by identity
Browse filesThe 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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<
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 {
|