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 { searchIcon } from "./icons"; import { t } from "../i18n"; import { isShallowEqual } from "../utils"; import { supportsResizeObserver } from "../constants"; import type { MarkRequired } from "../utility-types"; 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> ) : ( <React.Fragment>{children}</React.Fragment> ); 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 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 searchTermNormalized = searchTerm.trim().toLowerCase(); const filteredCollaborators = searchTermNormalized ? uniqueCollaboratorsArray.filter((collaborator) => collaborator.username?.toLowerCase().includes(searchTerm), ) : uniqueCollaboratorsArray; 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 onOpenChange={(isOpen) => { if (!isOpen) { setSearchTerm(""); } }} > <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 style={{ overflow: "hidden" }}> {uniqueCollaboratorsArray.length >= SHOW_COLLABORATORS_FILTER_AT && ( <div className="UserList__search-wrapper"> {searchIcon} <input className="UserList__search" type="text" placeholder={t("userList.search.placeholder")} value={searchTerm} onChange={(e) => { setSearchTerm(e.target.value); }} /> </div> )} <div className="dropdown-menu UserList__collaborators"> {filteredCollaborators.length === 0 && ( <div className="UserList__collaborators__empty"> {t("userList.search.empty")} </div> )} <div className="UserList__hint"> {t("userList.hint.text")} </div> {filteredCollaborators.map((collaborator) => renderCollaborator({ actionManager, collaborator, socketId: collaborator.socketId, withName: true, isBeingFollowed: collaborator.socketId === userToFollow, }), )} </div> </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; }, );