From e33a1c0e85a9fae8636deb9b9eb7079825de8337 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:20:18 +0200 Subject: [PATCH] feat(packages/excalidraw): do not dedupe collaborators (#11835) --- packages/excalidraw/components/UserList.scss | 14 ++++ .../excalidraw/components/UserList.test.tsx | 79 +++++++++++++++++++ packages/excalidraw/components/UserList.tsx | 50 ++++-------- packages/excalidraw/types.ts | 2 +- 4 files changed, 111 insertions(+), 34 deletions(-) create mode 100644 packages/excalidraw/components/UserList.test.tsx diff --git a/packages/excalidraw/components/UserList.scss b/packages/excalidraw/components/UserList.scss index 887e7d70e0..6662d2945d 100644 --- a/packages/excalidraw/components/UserList.scss +++ b/packages/excalidraw/components/UserList.scss @@ -70,6 +70,10 @@ flex-wrap: wrap; align-items: center; gap: var(--avatarList-gap); + + > .is-current-user { + order: -1; + } } .UserList__collaborator-name { @@ -189,6 +193,8 @@ } .UserList__collaborators { + display: flex; + flex-direction: column; top: auto; max-height: 50vh; // leave room for the focus/hover ring (box-shadow) on collaborator rows — @@ -197,6 +203,14 @@ // every row, and top/bottom for the first/last one padding: 2px; box-sizing: border-box; + + > .hint { + order: -2; + } + + > .is-current-user { + order: -1; + } } .UserList__menu-divider { diff --git a/packages/excalidraw/components/UserList.test.tsx b/packages/excalidraw/components/UserList.test.tsx new file mode 100644 index 0000000000..89fe6d893e --- /dev/null +++ b/packages/excalidraw/components/UserList.test.tsx @@ -0,0 +1,79 @@ +import React from "react"; + +import { Excalidraw } from "../index"; +import { + act, + fireEvent, + getByText, + render, + waitFor, +} from "../tests/test-utils"; + +import type { Collaborator, SocketId } from "../types"; + +const socketId = (id: string) => id as SocketId; + +describe("UserList", () => { + it("retains the local dropdown when the same account has multiple clients", async () => { + const { container } = await render( + Spotlight me} + />, + ); + const currentSocketId = socketId("current"); + const otherSocketId = socketId("other"); + + act(() => { + window.h.app.updateScene({ + collaborators: new Map([ + [ + otherSocketId, + { + id: "user", + socketId: otherSocketId, + username: "Ada", + isCurrentUser: false, + }, + ], + [ + currentSocketId, + { + id: "user", + socketId: currentSocketId, + username: "Ada", + isCurrentUser: true, + }, + ], + ]), + }); + }); + + await waitFor(() => { + expect(container.querySelectorAll(".UserList__pill")).toHaveLength(1); + }); + + const currentUserPill = + container.querySelector(".UserList__pill"); + expect(currentUserPill).not.toBeNull(); + + // Radix Popover measures its content when opened. + (global as any).ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + fireEvent.click(currentUserPill!); + + await waitFor(() => { + const dropdown = document.querySelector(".UserList__collaborators"); + const dropdownCollaborators = dropdown?.querySelectorAll( + ".UserList__collaborator", + ); + expect(dropdownCollaborators).toHaveLength(2); + expect( + dropdown?.querySelectorAll(".UserList__collaborator.is-current-user"), + ).toHaveLength(1); + expect(getByText(document.body, "Spotlight me")).toBeVisible(); + }); + }); +}); diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index c7b16cf407..f1a460ff19 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -4,8 +4,6 @@ import React, { useLayoutEffect } from "react"; import { supportsResizeObserver, isShallowEqual } from "@excalidraw/common"; -import type { MarkRequired } from "@excalidraw/common/utility-types"; - import { t } from "../i18n"; import { useExcalidrawActionManager } from "./App"; @@ -27,9 +25,6 @@ export type GoToCollaboratorComponentProps = { isBeingFollowed: boolean; }; -/** collaborator user id or socket id (fallback) */ -type ClientId = string & { _brand: "UserId" }; - const DEFAULT_MAX_AVATARS = 4; const SHOW_COLLABORATORS_FILTER_AT = 8; @@ -138,28 +133,21 @@ export const UserList = React.memo( ? currentUserControls(!!mobile) : currentUserControls; - const uniqueCollaboratorsMap = new Map< - ClientId, - MarkRequired - >(); - - collaborators.forEach((collaborator, socketId) => { - const userId = (collaborator.id || socketId) as ClientId; - uniqueCollaboratorsMap.set( - // filter on user id, else fall back on unique socketId - userId, - { ...collaborator, socketId }, - ); - }); - - const uniqueCollaboratorsArray = Array.from( - uniqueCollaboratorsMap.values(), + const collaboratorsArray = Array.from( + collaborators, + ([socketId, collaborator]) => ({ + ...collaborator, + socketId, + }), ).filter((collaborator) => collaborator.username?.trim()); + const currentUser = collaboratorsArray.find((c) => c.isCurrentUser); + const otherCollaborators = collaboratorsArray.filter( + (c) => !c.isCurrentUser, + ); const [searchTerm, setSearchTerm] = React.useState(""); - const filteredCollaborators = uniqueCollaboratorsArray.filter( - (collaborator) => - collaborator.username?.toLowerCase().includes(searchTerm), + const filteredCollaborators = collaboratorsArray.filter((collaborator) => + collaborator.username?.toLowerCase().includes(searchTerm), ); const userListWrapper = React.useRef(null); @@ -195,11 +183,6 @@ export const UserList = React.memo( DEFAULT_MAX_AVATARS * AVATAR_SLOT_WIDTH, ); - const currentUser = uniqueCollaboratorsArray.find((c) => c.isCurrentUser); - const otherCollaborators = uniqueCollaboratorsArray.filter( - (c) => !c.isCurrentUser, - ); - // the current user's avatar is always shown, reserved as the last // slot — it's the single entry point for the collaborators + // currentUserControls dropdown, so it's never hidden by/counted in @@ -255,8 +238,7 @@ export const UserList = React.memo( sideOffset={10} > - {uniqueCollaboratorsArray.length >= - SHOW_COLLABORATORS_FILTER_AT && ( + {collaboratorsArray.length >= SHOW_COLLABORATORS_FILTER_AT && ( 0 ? [ -
{t("userList.hint.text")}
, +
+ {t("userList.hint.text")} +
, filteredCollaborators.map((c) => renderCollaborator({ actionManager, @@ -309,7 +293,7 @@ export const UserList = React.memo( return mobile ? (
- {uniqueCollaboratorsArray.map((collaborator) => + {collaboratorsArray.map((collaborator) => renderCollaborator({ actionManager, collaborator, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 347eaf2421..f4c0dfce88 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -84,7 +84,7 @@ export type Collaborator = Readonly<{ // The url of the collaborator's avatar, defaults to username initials // if not present avatarUrl?: string; - // user id. If supplied, we'll filter out duplicates when rendering user avatars. + // Stable user id. A user may have multiple collaborators, one per client. id?: string; socketId?: SocketId; isCurrentUser?: boolean;