aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/UserList.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/UserList.tsx')
-rw-r--r--packages/excalidraw/components/UserList.tsx293
1 files changed, 293 insertions, 0 deletions
diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx
new file mode 100644
index 0000000..9fbfc1a
--- /dev/null
+++ b/packages/excalidraw/components/UserList.tsx
@@ -0,0 +1,293 @@
+import "./UserList.scss";
+
+import React, { useLayoutEffect } from "react";
+import clsx from "clsx";
+import type { Collaborator, SocketId } from "../types";
+import { Tooltip } from "./Tooltip";
+import { useExcalidrawActionManager } from "./App";
+import type { ActionManager } from "../actions/manager";
+
+import * as Popover from "@radix-ui/react-popover";
+import { Island } from "./Island";
+import { QuickSearch } from "./QuickSearch";
+import { t } from "../i18n";
+import { isShallowEqual } from "../utils";
+import { supportsResizeObserver } from "../constants";
+import type { MarkRequired } from "../utility-types";
+import { ScrollableList } from "./ScrollableList";
+
+export type GoToCollaboratorComponentProps = {
+ socketId: SocketId;
+ collaborator: Collaborator;
+ withName: boolean;
+ 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;
+
+const ConditionalTooltipWrapper = ({
+ shouldWrap,
+ children,
+ username,
+}: {
+ shouldWrap: boolean;
+ children: React.ReactNode;
+ username?: string | null;
+}) =>
+ shouldWrap ? (
+ <Tooltip label={username || "Unknown user"}>{children}</Tooltip>
+ ) : (
+ <>{children}</>
+ );
+
+const renderCollaborator = ({
+ actionManager,
+ collaborator,
+ socketId,
+ withName = false,
+ shouldWrapWithTooltip = false,
+ isBeingFollowed,
+}: {
+ actionManager: ActionManager;
+ collaborator: Collaborator;
+ socketId: SocketId;
+ withName?: boolean;
+ shouldWrapWithTooltip?: boolean;
+ isBeingFollowed: boolean;
+}) => {
+ const data: GoToCollaboratorComponentProps = {
+ socketId,
+ collaborator,
+ withName,
+ isBeingFollowed,
+ };
+ const avatarJSX = actionManager.renderAction("goToCollaborator", data);
+
+ return (
+ <ConditionalTooltipWrapper
+ key={socketId}
+ username={collaborator.username}
+ shouldWrap={shouldWrapWithTooltip}
+ >
+ {avatarJSX}
+ </ConditionalTooltipWrapper>
+ );
+};
+
+type UserListUserObject = Pick<
+ Collaborator,
+ | "avatarUrl"
+ | "id"
+ | "socketId"
+ | "username"
+ | "isInCall"
+ | "isSpeaking"
+ | "isMuted"
+>;
+
+type UserListProps = {
+ className?: string;
+ mobile?: boolean;
+ collaborators: Map<SocketId, UserListUserObject>;
+ userToFollow: SocketId | null;
+};
+
+const collaboratorComparatorKeys = [
+ "avatarUrl",
+ "id",
+ "socketId",
+ "username",
+ "isInCall",
+ "isSpeaking",
+ "isMuted",
+] as const;
+
+export const UserList = React.memo(
+ ({ className, mobile, collaborators, userToFollow }: UserListProps) => {
+ const actionManager = useExcalidrawActionManager();
+
+ const uniqueCollaboratorsMap = new Map<
+ ClientId,
+ MarkRequired<Collaborator, "socketId">
+ >();
+
+ 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(),
+ ).filter((collaborator) => collaborator.username?.trim());
+
+ const [searchTerm, setSearchTerm] = React.useState("");
+ const filteredCollaborators = uniqueCollaboratorsArray.filter(
+ (collaborator) =>
+ collaborator.username?.toLowerCase().includes(searchTerm),
+ );
+
+ const userListWrapper = React.useRef<HTMLDivElement | null>(null);
+
+ useLayoutEffect(() => {
+ if (userListWrapper.current) {
+ const updateMaxAvatars = (width: number) => {
+ const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38)));
+ setMaxAvatars(maxAvatars);
+ };
+
+ updateMaxAvatars(userListWrapper.current.clientWidth);
+
+ if (!supportsResizeObserver) {
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const { width } = entry.contentRect;
+ updateMaxAvatars(width);
+ }
+ });
+
+ resizeObserver.observe(userListWrapper.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }
+ }, []);
+
+ const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
+
+ const firstNCollaborators = uniqueCollaboratorsArray.slice(
+ 0,
+ maxAvatars - 1,
+ );
+
+ const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
+ renderCollaborator({
+ actionManager,
+ collaborator,
+ socketId: collaborator.socketId,
+ shouldWrapWithTooltip: true,
+ isBeingFollowed: collaborator.socketId === userToFollow,
+ }),
+ );
+
+ return mobile ? (
+ <div className={clsx("UserList UserList_mobile", className)}>
+ {uniqueCollaboratorsArray.map((collaborator) =>
+ renderCollaborator({
+ actionManager,
+ collaborator,
+ socketId: collaborator.socketId,
+ shouldWrapWithTooltip: true,
+ isBeingFollowed: collaborator.socketId === userToFollow,
+ }),
+ )}
+ </div>
+ ) : (
+ <div className="UserList__wrapper" ref={userListWrapper}>
+ <div
+ className={clsx("UserList", className)}
+ style={{ [`--max-avatars` as any]: maxAvatars }}
+ >
+ {firstNAvatarsJSX}
+
+ {uniqueCollaboratorsArray.length > maxAvatars - 1 && (
+ <Popover.Root>
+ <Popover.Trigger className="UserList__more">
+ +{uniqueCollaboratorsArray.length - maxAvatars + 1}
+ </Popover.Trigger>
+ <Popover.Content
+ style={{
+ zIndex: 2,
+ width: "15rem",
+ textAlign: "left",
+ }}
+ align="end"
+ sideOffset={10}
+ >
+ <Island padding={2}>
+ {uniqueCollaboratorsArray.length >=
+ SHOW_COLLABORATORS_FILTER_AT && (
+ <QuickSearch
+ placeholder={t("quickSearch.placeholder")}
+ onChange={setSearchTerm}
+ />
+ )}
+ <ScrollableList
+ className={"dropdown-menu UserList__collaborators"}
+ placeholder={t("userList.empty")}
+ >
+ {/* The list checks for `Children.count()`, hence defensively returning empty list */}
+ {filteredCollaborators.length > 0
+ ? [
+ <div className="hint">{t("userList.hint.text")}</div>,
+ filteredCollaborators.map((collaborator) =>
+ renderCollaborator({
+ actionManager,
+ collaborator,
+ socketId: collaborator.socketId,
+ withName: true,
+ isBeingFollowed:
+ collaborator.socketId === userToFollow,
+ }),
+ ),
+ ]
+ : []}
+ </ScrollableList>
+ <Popover.Arrow
+ width={20}
+ height={10}
+ style={{
+ fill: "var(--popup-bg-color)",
+ filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
+ }}
+ />
+ </Island>
+ </Popover.Content>
+ </Popover.Root>
+ )}
+ </div>
+ </div>
+ );
+ },
+ (prev, next) => {
+ if (
+ prev.collaborators.size !== next.collaborators.size ||
+ prev.mobile !== next.mobile ||
+ prev.className !== next.className ||
+ prev.userToFollow !== next.userToFollow
+ ) {
+ return false;
+ }
+
+ const nextCollaboratorSocketIds = next.collaborators.keys();
+
+ for (const [socketId, collaborator] of prev.collaborators) {
+ const nextCollaborator = next.collaborators.get(socketId);
+ if (
+ !nextCollaborator ||
+ // this checks order of collaborators in the map is the same
+ // as previous render
+ socketId !== nextCollaboratorSocketIds.next().value ||
+ !isShallowEqual(
+ collaborator,
+ nextCollaborator,
+ collaboratorComparatorKeys,
+ )
+ ) {
+ return false;
+ }
+ }
+ return true;
+ },
+);