You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/packages/excalidraw/components/UserList.tsx

236 lines
6.4 KiB
TypeScript

import "./UserList.scss";
import React from "react";
import clsx from "clsx";
import { Collaborator, SocketId } from "../types";
import { Tooltip } from "./Tooltip";
feat: new Menu Component API (#6034) * feat: new Menu Component API * allow valid children types * introduce menu group to group items * Add lang footer * use display name * displayName * define types inside * fix default menu * add json export to menu * fix * simplify expression * put open menu into own compo to optimize perf So that we don't rerun `useOutsideClickHook` (and rebind event listeners all the time) * naming tweaks * rename MenuComponents->MenuDefaultItems and export default items from Menu.Items * import Menu.scss in Menu.tsx * move menu scss to excal app * Don't filter children inside menu group * move E+ out of socials * support style prop for MenuItem and MenuGroup * Support header in menu group and add Excalidraw links header for default items in social section * rename header to title * fix padding for lang * render menu in mobile * review fixes * tweaks * Export collaborators and show in mobile menu * revert .env * lint :p * again lint * show correct actions in view mode for mobile * Whitelist Collaborators Comp * mobile styling * padding * don't show nerds when menu open in mobile * lint :( * hide shortcuts * refactor userlist to support mobile and keep a wrapper comp for excal app * use only UserList * render only on mobile for default items * remove unused hooks * Show collab button in menu when onCollabButtonClick present and hide export when UIOptions.canvasActions.export is false * fix tests * lint * inject userlist inside menu on mobile * revert userlist * move menu socials to default menu * fix collab * use meny in library * Make Menu generic and create hamburgemenu for public excal menu and use menu in library as well * use appState.openMenu for mobile * fix tests * styling fixes and support style and class name in menu content * fix test * rename MenuDefaultItems->DefaultItems * move footer css to its own comp * rename HamburgerMenu -> MainMenu * rename menu -> dropdownMenu and update classes, onClick->onToggle * close main menu when dialog closes * by bye filtering * update docs * fix lint * update example, docs for useDevice and footer in mobile, rename menu ->DropDownMenu everywhere * spec * remove isMenuOpenAtom and set openMenu as canvas for main menu, render decreases in specs :) * [temp] remove cyclic depenedency to fix build * hack- update appstate to sync lang change * Add more specs * wip: rewrite MainMenu footer * fix margin * fix snaps * not needed as lang list no more imported * simplify custom footer rendering * Add DropdownMenuItemLink and DropdownMenuItemCustom and update API, docs * fix `MainMenu.ItemCustom` * naming * use onSelect and base class for custom items * fix lint * fix snap * use custom item for lang * update docs * fix * properly use `MainMenu.ItemCustom` for `LanguageList` * add margin top to custom items * flex Co-authored-by: dwelle <luzar.david@gmail.com>
2 years ago
import { useExcalidrawActionManager } from "./App";
import { 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";
const FIRST_N_AVATARS = 3;
const SHOW_COLLABORATORS_FILTER_AT = 8;
const ConditionalTooltipWrapper = ({
shouldWrap,
children,
clientId,
username,
}: {
shouldWrap: boolean;
children: React.ReactNode;
username?: string | null;
clientId: string;
}) =>
shouldWrap ? (
<Tooltip label={username || "Unknown user"} key={clientId}>
{children}
</Tooltip>
) : (
<React.Fragment key={clientId}>{children}</React.Fragment>
);
const renderCollaborator = ({
actionManager,
collaborator,
clientId,
withName = false,
shouldWrapWithTooltip = false,
}: {
actionManager: ActionManager;
collaborator: Collaborator;
clientId: string;
withName?: boolean;
shouldWrapWithTooltip?: boolean;
}) => {
const avatarJSX = actionManager.renderAction("goToCollaborator", [
clientId,
collaborator,
withName,
]);
return (
<ConditionalTooltipWrapper
key={clientId}
clientId={clientId}
username={collaborator.username}
shouldWrap={shouldWrapWithTooltip}
>
{avatarJSX}
</ConditionalTooltipWrapper>
);
};
type UserListUserObject = Pick<
Collaborator,
"avatarUrl" | "id" | "socketId" | "username"
>;
type UserListProps = {
className?: string;
mobile?: boolean;
collaborators: Map<SocketId, UserListUserObject>;
};
const collaboratorComparatorKeys = [
"avatarUrl",
"id",
"socketId",
"username",
] as const;
export const UserList = React.memo(
({ className, mobile, collaborators }: UserListProps) => {
const actionManager = useExcalidrawActionManager();
const uniqueCollaboratorsMap = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaboratorsMap.set(
// filter on user id, else fall back on unique socketId
collaborator.id || socketId,
{ ...collaborator, socketId },
);
});
// const uniqueCollaboratorsMap = sampleCollaborators;
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
([_, collaborator]) => Object.keys(collaborator).length !== 1,
);
const [searchTerm, setSearchTerm] = React.useState("");
if (uniqueCollaboratorsArray.length === 0) {
return null;
}
const searchTermNormalized = searchTerm.trim().toLowerCase();
const filteredCollaborators = searchTermNormalized
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
collaborator.username?.toLowerCase().includes(searchTerm),
)
: uniqueCollaboratorsArray;
const firstNCollaborators = uniqueCollaboratorsArray.slice(
0,
FIRST_N_AVATARS,
);
const firstNAvatarsJSX = firstNCollaborators.map(
([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
}),
);
return mobile ? (
<div className={clsx("UserList UserList_mobile", className)}>
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
}),
)}
</div>
) : (
<div className={clsx("UserList", className)}>
{firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
<Popover.Root
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
</Popover.Trigger>
<Popover.Content
style={{
zIndex: 2,
width: "12rem",
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(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
withName: true,
}),
)}
</div>
</Island>
</Popover.Content>
</Popover.Root>
)}
</div>
);
},
(prev, next) => {
if (
prev.collaborators.size !== next.collaborators.size ||
prev.mobile !== next.mobile ||
prev.className !== next.className
) {
return false;
}
for (const [socketId, collaborator] of prev.collaborators) {
const nextCollaborator = next.collaborators.get(socketId);
if (
!nextCollaborator ||
!isShallowEqual(
collaborator,
nextCollaborator,
collaboratorComparatorKeys,
)
) {
return false;
}
}
return true;
},
);