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 ? ( {children} ) : ( <>{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 ( {avatarJSX} ); }; type UserListUserObject = Pick< Collaborator, | "avatarUrl" | "id" | "socketId" | "username" | "isInCall" | "isSpeaking" | "isMuted" >; type UserListProps = { className?: string; mobile?: boolean; collaborators: Map; 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 >(); 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(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 ? (
{uniqueCollaboratorsArray.map((collaborator) => renderCollaborator({ actionManager, collaborator, socketId: collaborator.socketId, shouldWrapWithTooltip: true, isBeingFollowed: collaborator.socketId === userToFollow, }), )}
) : (
{firstNAvatarsJSX} {uniqueCollaboratorsArray.length > maxAvatars - 1 && ( +{uniqueCollaboratorsArray.length - maxAvatars + 1} {uniqueCollaboratorsArray.length >= SHOW_COLLABORATORS_FILTER_AT && ( )} {/* The list checks for `Children.count()`, hence defensively returning empty list */} {filteredCollaborators.length > 0 ? [
{t("userList.hint.text")}
, filteredCollaborators.map((collaborator) => renderCollaborator({ actionManager, collaborator, socketId: collaborator.socketId, withName: true, isBeingFollowed: collaborator.socketId === userToFollow, }), ), ] : []}
)}
); }, (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; }, );