361 lines
10 KiB
TypeScript
361 lines
10 KiB
TypeScript
import { Popover } from "radix-ui";
|
|
import clsx from "clsx";
|
|
import React, { useLayoutEffect } from "react";
|
|
|
|
import { isShallowEqual } from "@excalidraw/common";
|
|
|
|
import { t } from "../i18n";
|
|
|
|
import { useExcalidrawActionManager } from "./App";
|
|
import { chevronDownIcon } from "./icons";
|
|
import { Island } from "./Island";
|
|
import { QuickSearch } from "./QuickSearch";
|
|
import { ScrollableList } from "./ScrollableList";
|
|
import { Tooltip } from "./Tooltip";
|
|
|
|
import "./UserList.scss";
|
|
|
|
import type { ActionManager } from "../actions/manager";
|
|
import type { Collaborator, SocketId } from "../types";
|
|
|
|
export type GoToCollaboratorComponentProps = {
|
|
socketId: SocketId;
|
|
collaborator: Collaborator;
|
|
withName: boolean;
|
|
isBeingFollowed: boolean;
|
|
};
|
|
|
|
const DEFAULT_MAX_AVATARS = 4;
|
|
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
|
|
|
// avatar (28px) + gap (10px)
|
|
const AVATAR_SLOT_WIDTH = 38;
|
|
// the current user's pill (avatar + chevron + its own padding/border) is
|
|
// wider than a plain avatar — reserving this extra width for it keeps it
|
|
// from ever being the item that tips the row over and wraps onto its own
|
|
// line (chevron 15px + pill's right padding 4px + pill's border 2px)
|
|
const PILL_EXTRA_WIDTH = 21;
|
|
|
|
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"
|
|
| "isCurrentUser"
|
|
>;
|
|
|
|
type UserListProps = {
|
|
className?: string;
|
|
mobile?: boolean;
|
|
collaborators: Map<SocketId, UserListUserObject>;
|
|
userToFollow: SocketId | null;
|
|
currentUserControls?:
|
|
| React.ReactNode
|
|
| ((isMobile: boolean) => React.ReactNode);
|
|
};
|
|
|
|
const collaboratorComparatorKeys = [
|
|
"avatarUrl",
|
|
"id",
|
|
"socketId",
|
|
"username",
|
|
"isInCall",
|
|
"isSpeaking",
|
|
"isMuted",
|
|
"isCurrentUser",
|
|
] as const;
|
|
|
|
export const UserList = React.memo(
|
|
({
|
|
className,
|
|
mobile,
|
|
collaborators,
|
|
userToFollow,
|
|
currentUserControls,
|
|
}: UserListProps) => {
|
|
const actionManager = useExcalidrawActionManager();
|
|
|
|
const resolvedCurrentUserControls =
|
|
typeof currentUserControls === "function"
|
|
? currentUserControls(!!mobile)
|
|
: currentUserControls;
|
|
|
|
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 = collaboratorsArray.filter((collaborator) =>
|
|
collaborator.username?.toLowerCase().includes(searchTerm),
|
|
);
|
|
|
|
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
if (userListWrapper.current) {
|
|
const ownerWindow = userListWrapper.current.ownerDocument.defaultView;
|
|
const updateWrapperWidth = (width: number) => {
|
|
setWrapperWidth(width);
|
|
};
|
|
|
|
updateWrapperWidth(userListWrapper.current.clientWidth);
|
|
|
|
if (!ownerWindow || typeof ownerWindow.ResizeObserver !== "function") {
|
|
return;
|
|
}
|
|
|
|
const resizeObserver = new ownerWindow.ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width } = entry.contentRect;
|
|
updateWrapperWidth(width);
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(userListWrapper.current);
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}
|
|
}, []);
|
|
|
|
const [wrapperWidth, setWrapperWidth] = React.useState(
|
|
DEFAULT_MAX_AVATARS * AVATAR_SLOT_WIDTH,
|
|
);
|
|
|
|
// 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
|
|
// overflow. Its width (wider than a plain avatar) is carved out of the
|
|
// available width up front so it always shares a row with the other
|
|
// avatars — if there's only room for the pill itself, it renders alone
|
|
// rather than wrapping onto its own line.
|
|
const availableWidth = currentUser
|
|
? wrapperWidth - PILL_EXTRA_WIDTH
|
|
: wrapperWidth;
|
|
const totalSlots = Math.max(
|
|
1,
|
|
Math.min(8, Math.floor(availableWidth / AVATAR_SLOT_WIDTH)),
|
|
);
|
|
const otherSlots = currentUser ? Math.max(0, totalSlots - 1) : totalSlots;
|
|
const visibleOthers = otherCollaborators.slice(0, otherSlots);
|
|
|
|
const firstNCollaborators = currentUser
|
|
? [...visibleOthers, currentUser]
|
|
: visibleOthers;
|
|
|
|
const firstNAvatarsJSX = firstNCollaborators.map((collaborator) => {
|
|
const avatarJSX = renderCollaborator({
|
|
actionManager,
|
|
collaborator,
|
|
socketId: collaborator.socketId,
|
|
shouldWrapWithTooltip: true,
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
});
|
|
|
|
if (!collaborator.isCurrentUser) {
|
|
return avatarJSX;
|
|
}
|
|
|
|
// current user's avatar is the single entry point for the "who's
|
|
// here" list + currentUserControls dropdown
|
|
return (
|
|
<Popover.Root key={collaborator.socketId}>
|
|
<Popover.Trigger asChild>
|
|
<div className="UserList__pill">
|
|
{avatarJSX}
|
|
<div className="UserList__pill-chevron">{chevronDownIcon}</div>
|
|
</div>
|
|
</Popover.Trigger>
|
|
<Popover.Content
|
|
className="focus-visible-none"
|
|
style={{
|
|
zIndex: 2,
|
|
width: "15rem",
|
|
textAlign: "left",
|
|
}}
|
|
align="end"
|
|
sideOffset={10}
|
|
>
|
|
<Island padding={2}>
|
|
{collaboratorsArray.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 key="hint" className="hint">
|
|
{t("userList.hint.text")}
|
|
</div>,
|
|
filteredCollaborators.map((c) =>
|
|
renderCollaborator({
|
|
actionManager,
|
|
collaborator: c,
|
|
socketId: c.socketId,
|
|
withName: true,
|
|
isBeingFollowed: c.socketId === userToFollow,
|
|
}),
|
|
),
|
|
]
|
|
: []}
|
|
</ScrollableList>
|
|
{resolvedCurrentUserControls && (
|
|
<>
|
|
<div className="UserList__menu-divider" />
|
|
<Popover.Close asChild>
|
|
<div className="dropdown-menu">
|
|
{resolvedCurrentUserControls}
|
|
</div>
|
|
</Popover.Close>
|
|
</>
|
|
)}
|
|
<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>
|
|
);
|
|
});
|
|
|
|
return mobile ? (
|
|
<div className={clsx("UserList UserList_mobile", className)}>
|
|
<div className="UserList_mobile__avatars">
|
|
{collaboratorsArray.map((collaborator) =>
|
|
renderCollaborator({
|
|
actionManager,
|
|
collaborator,
|
|
socketId: collaborator.socketId,
|
|
shouldWrapWithTooltip: true,
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
}),
|
|
)}
|
|
</div>
|
|
{resolvedCurrentUserControls && (
|
|
<>
|
|
<div className="UserList__menu-divider" />
|
|
{resolvedCurrentUserControls}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="UserList__wrapper" ref={userListWrapper}>
|
|
<div
|
|
className={clsx("UserList", className)}
|
|
style={{
|
|
[`--max-avatars` as any]:
|
|
firstNCollaborators.length +
|
|
(currentUser ? PILL_EXTRA_WIDTH / AVATAR_SLOT_WIDTH : 0),
|
|
}}
|
|
>
|
|
{firstNAvatarsJSX}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
(prev, next) => {
|
|
if (
|
|
prev.collaborators.size !== next.collaborators.size ||
|
|
prev.mobile !== next.mobile ||
|
|
prev.className !== next.className ||
|
|
prev.userToFollow !== next.userToFollow ||
|
|
prev.currentUserControls !== next.currentUserControls
|
|
) {
|
|
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;
|
|
},
|
|
);
|