|
|
@ -1,6 +1,6 @@
|
|
|
|
import "./UserList.scss";
|
|
|
|
import "./UserList.scss";
|
|
|
|
|
|
|
|
|
|
|
|
import React from "react";
|
|
|
|
import React, { useLayoutEffect } from "react";
|
|
|
|
import clsx from "clsx";
|
|
|
|
import clsx from "clsx";
|
|
|
|
import { Collaborator, SocketId } from "../types";
|
|
|
|
import { Collaborator, SocketId } from "../types";
|
|
|
|
import { Tooltip } from "./Tooltip";
|
|
|
|
import { Tooltip } from "./Tooltip";
|
|
|
@ -12,9 +12,11 @@ import { Island } from "./Island";
|
|
|
|
import { searchIcon } from "./icons";
|
|
|
|
import { searchIcon } from "./icons";
|
|
|
|
import { t } from "../i18n";
|
|
|
|
import { t } from "../i18n";
|
|
|
|
import { isShallowEqual } from "../utils";
|
|
|
|
import { isShallowEqual } from "../utils";
|
|
|
|
|
|
|
|
import { supportsResizeObserver } from "../constants";
|
|
|
|
|
|
|
|
import { MarkRequired } from "../utility-types";
|
|
|
|
|
|
|
|
|
|
|
|
export type GoToCollaboratorComponentProps = {
|
|
|
|
export type GoToCollaboratorComponentProps = {
|
|
|
|
clientId: ClientId;
|
|
|
|
socketId: SocketId;
|
|
|
|
collaborator: Collaborator;
|
|
|
|
collaborator: Collaborator;
|
|
|
|
withName: boolean;
|
|
|
|
withName: boolean;
|
|
|
|
isBeingFollowed: boolean;
|
|
|
|
isBeingFollowed: boolean;
|
|
|
@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = {
|
|
|
|
/** collaborator user id or socket id (fallback) */
|
|
|
|
/** collaborator user id or socket id (fallback) */
|
|
|
|
type ClientId = string & { _brand: "UserId" };
|
|
|
|
type ClientId = string & { _brand: "UserId" };
|
|
|
|
|
|
|
|
|
|
|
|
const FIRST_N_AVATARS = 3;
|
|
|
|
const DEFAULT_MAX_AVATARS = 4;
|
|
|
|
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
|
|
|
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
|
|
|
|
|
|
|
|
|
|
|
const ConditionalTooltipWrapper = ({
|
|
|
|
const ConditionalTooltipWrapper = ({
|
|
|
|
shouldWrap,
|
|
|
|
shouldWrap,
|
|
|
|
children,
|
|
|
|
children,
|
|
|
|
clientId,
|
|
|
|
|
|
|
|
username,
|
|
|
|
username,
|
|
|
|
}: {
|
|
|
|
}: {
|
|
|
|
shouldWrap: boolean;
|
|
|
|
shouldWrap: boolean;
|
|
|
|
children: React.ReactNode;
|
|
|
|
children: React.ReactNode;
|
|
|
|
username?: string | null;
|
|
|
|
username?: string | null;
|
|
|
|
clientId: ClientId;
|
|
|
|
|
|
|
|
}) =>
|
|
|
|
}) =>
|
|
|
|
shouldWrap ? (
|
|
|
|
shouldWrap ? (
|
|
|
|
<Tooltip label={username || "Unknown user"} key={clientId}>
|
|
|
|
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
|
|
|
{children}
|
|
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
) : (
|
|
|
|
) : (
|
|
|
|
<React.Fragment key={clientId}>{children}</React.Fragment>
|
|
|
|
<React.Fragment>{children}</React.Fragment>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const renderCollaborator = ({
|
|
|
|
const renderCollaborator = ({
|
|
|
|
actionManager,
|
|
|
|
actionManager,
|
|
|
|
collaborator,
|
|
|
|
collaborator,
|
|
|
|
clientId,
|
|
|
|
socketId,
|
|
|
|
withName = false,
|
|
|
|
withName = false,
|
|
|
|
shouldWrapWithTooltip = false,
|
|
|
|
shouldWrapWithTooltip = false,
|
|
|
|
isBeingFollowed,
|
|
|
|
isBeingFollowed,
|
|
|
|
}: {
|
|
|
|
}: {
|
|
|
|
actionManager: ActionManager;
|
|
|
|
actionManager: ActionManager;
|
|
|
|
collaborator: Collaborator;
|
|
|
|
collaborator: Collaborator;
|
|
|
|
clientId: ClientId;
|
|
|
|
socketId: SocketId;
|
|
|
|
withName?: boolean;
|
|
|
|
withName?: boolean;
|
|
|
|
shouldWrapWithTooltip?: boolean;
|
|
|
|
shouldWrapWithTooltip?: boolean;
|
|
|
|
isBeingFollowed: boolean;
|
|
|
|
isBeingFollowed: boolean;
|
|
|
|
}) => {
|
|
|
|
}) => {
|
|
|
|
const data: GoToCollaboratorComponentProps = {
|
|
|
|
const data: GoToCollaboratorComponentProps = {
|
|
|
|
clientId,
|
|
|
|
socketId,
|
|
|
|
collaborator,
|
|
|
|
collaborator,
|
|
|
|
withName,
|
|
|
|
withName,
|
|
|
|
isBeingFollowed,
|
|
|
|
isBeingFollowed,
|
|
|
@ -70,8 +68,7 @@ const renderCollaborator = ({
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<ConditionalTooltipWrapper
|
|
|
|
<ConditionalTooltipWrapper
|
|
|
|
key={clientId}
|
|
|
|
key={socketId}
|
|
|
|
clientId={clientId}
|
|
|
|
|
|
|
|
username={collaborator.username}
|
|
|
|
username={collaborator.username}
|
|
|
|
shouldWrap={shouldWrapWithTooltip}
|
|
|
|
shouldWrap={shouldWrapWithTooltip}
|
|
|
|
>
|
|
|
|
>
|
|
|
@ -82,7 +79,13 @@ const renderCollaborator = ({
|
|
|
|
|
|
|
|
|
|
|
|
type UserListUserObject = Pick<
|
|
|
|
type UserListUserObject = Pick<
|
|
|
|
Collaborator,
|
|
|
|
Collaborator,
|
|
|
|
"avatarUrl" | "id" | "socketId" | "username"
|
|
|
|
| "avatarUrl"
|
|
|
|
|
|
|
|
| "id"
|
|
|
|
|
|
|
|
| "socketId"
|
|
|
|
|
|
|
|
| "username"
|
|
|
|
|
|
|
|
| "isInCall"
|
|
|
|
|
|
|
|
| "isSpeaking"
|
|
|
|
|
|
|
|
| "isMuted"
|
|
|
|
>;
|
|
|
|
>;
|
|
|
|
|
|
|
|
|
|
|
|
type UserListProps = {
|
|
|
|
type UserListProps = {
|
|
|
@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [
|
|
|
|
"id",
|
|
|
|
"id",
|
|
|
|
"socketId",
|
|
|
|
"socketId",
|
|
|
|
"username",
|
|
|
|
"username",
|
|
|
|
|
|
|
|
"isInCall",
|
|
|
|
|
|
|
|
"isSpeaking",
|
|
|
|
|
|
|
|
"isMuted",
|
|
|
|
] as const;
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
export const UserList = React.memo(
|
|
|
|
export const UserList = React.memo(
|
|
|
|
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
|
|
|
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
|
|
|
const actionManager = useExcalidrawActionManager();
|
|
|
|
const actionManager = useExcalidrawActionManager();
|
|
|
|
|
|
|
|
|
|
|
|
const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
|
|
|
|
const uniqueCollaboratorsMap = new Map<
|
|
|
|
|
|
|
|
ClientId,
|
|
|
|
|
|
|
|
MarkRequired<Collaborator, "socketId">
|
|
|
|
|
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
|
|
collaborators.forEach((collaborator, socketId) => {
|
|
|
|
collaborators.forEach((collaborator, socketId) => {
|
|
|
|
const userId = (collaborator.id || socketId) as ClientId;
|
|
|
|
const userId = (collaborator.id || socketId) as ClientId;
|
|
|
@ -114,35 +123,62 @@ export const UserList = React.memo(
|
|
|
|
);
|
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
|
|
|
|
const uniqueCollaboratorsArray = Array.from(
|
|
|
|
([_, collaborator]) => collaborator.username?.trim(),
|
|
|
|
uniqueCollaboratorsMap.values(),
|
|
|
|
);
|
|
|
|
).filter((collaborator) => collaborator.username?.trim());
|
|
|
|
|
|
|
|
|
|
|
|
const [searchTerm, setSearchTerm] = React.useState("");
|
|
|
|
const [searchTerm, setSearchTerm] = React.useState("");
|
|
|
|
|
|
|
|
|
|
|
|
if (uniqueCollaboratorsArray.length === 0) {
|
|
|
|
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
return 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 searchTermNormalized = searchTerm.trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
const filteredCollaborators = searchTermNormalized
|
|
|
|
const filteredCollaborators = searchTermNormalized
|
|
|
|
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
|
|
|
|
? uniqueCollaboratorsArray.filter((collaborator) =>
|
|
|
|
collaborator.username?.toLowerCase().includes(searchTerm),
|
|
|
|
collaborator.username?.toLowerCase().includes(searchTerm),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
: uniqueCollaboratorsArray;
|
|
|
|
: uniqueCollaboratorsArray;
|
|
|
|
|
|
|
|
|
|
|
|
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
|
|
|
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
FIRST_N_AVATARS,
|
|
|
|
maxAvatars - 1,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const firstNAvatarsJSX = firstNCollaborators.map(
|
|
|
|
const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
|
|
|
|
([clientId, collaborator]) =>
|
|
|
|
|
|
|
|
renderCollaborator({
|
|
|
|
renderCollaborator({
|
|
|
|
actionManager,
|
|
|
|
actionManager,
|
|
|
|
collaborator,
|
|
|
|
collaborator,
|
|
|
|
clientId,
|
|
|
|
socketId: collaborator.socketId,
|
|
|
|
shouldWrapWithTooltip: true,
|
|
|
|
shouldWrapWithTooltip: true,
|
|
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
|
|
}),
|
|
|
|
}),
|
|
|
@ -150,21 +186,25 @@ export const UserList = React.memo(
|
|
|
|
|
|
|
|
|
|
|
|
return mobile ? (
|
|
|
|
return mobile ? (
|
|
|
|
<div className={clsx("UserList UserList_mobile", className)}>
|
|
|
|
<div className={clsx("UserList UserList_mobile", className)}>
|
|
|
|
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
|
|
|
|
{uniqueCollaboratorsArray.map((collaborator) =>
|
|
|
|
renderCollaborator({
|
|
|
|
renderCollaborator({
|
|
|
|
actionManager,
|
|
|
|
actionManager,
|
|
|
|
collaborator,
|
|
|
|
collaborator,
|
|
|
|
clientId,
|
|
|
|
socketId: collaborator.socketId,
|
|
|
|
shouldWrapWithTooltip: true,
|
|
|
|
shouldWrapWithTooltip: true,
|
|
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
|
|
}),
|
|
|
|
}),
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
) : (
|
|
|
|
<div className={clsx("UserList", className)}>
|
|
|
|
<div className="UserList-wrapper" ref={userListWrapper}>
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
className={clsx("UserList", className)}
|
|
|
|
|
|
|
|
style={{ [`--max-avatars` as any]: maxAvatars }}
|
|
|
|
|
|
|
|
>
|
|
|
|
{firstNAvatarsJSX}
|
|
|
|
{firstNAvatarsJSX}
|
|
|
|
|
|
|
|
|
|
|
|
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
|
|
|
|
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
|
|
|
<Popover.Root
|
|
|
|
<Popover.Root
|
|
|
|
onOpenChange={(isOpen) => {
|
|
|
|
onOpenChange={(isOpen) => {
|
|
|
|
if (!isOpen) {
|
|
|
|
if (!isOpen) {
|
|
|
@ -173,12 +213,12 @@ export const UserList = React.memo(
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<Popover.Trigger className="UserList__more">
|
|
|
|
<Popover.Trigger className="UserList__more">
|
|
|
|
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
|
|
|
|
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
|
|
|
</Popover.Trigger>
|
|
|
|
</Popover.Trigger>
|
|
|
|
<Popover.Content
|
|
|
|
<Popover.Content
|
|
|
|
style={{
|
|
|
|
style={{
|
|
|
|
zIndex: 2,
|
|
|
|
zIndex: 2,
|
|
|
|
width: "13rem",
|
|
|
|
width: "15rem",
|
|
|
|
textAlign: "left",
|
|
|
|
textAlign: "left",
|
|
|
|
}}
|
|
|
|
}}
|
|
|
|
align="end"
|
|
|
|
align="end"
|
|
|
@ -209,11 +249,11 @@ export const UserList = React.memo(
|
|
|
|
<div className="UserList__hint">
|
|
|
|
<div className="UserList__hint">
|
|
|
|
{t("userList.hint.text")}
|
|
|
|
{t("userList.hint.text")}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{filteredCollaborators.map(([clientId, collaborator]) =>
|
|
|
|
{filteredCollaborators.map((collaborator) =>
|
|
|
|
renderCollaborator({
|
|
|
|
renderCollaborator({
|
|
|
|
actionManager,
|
|
|
|
actionManager,
|
|
|
|
collaborator,
|
|
|
|
collaborator,
|
|
|
|
clientId,
|
|
|
|
socketId: collaborator.socketId,
|
|
|
|
withName: true,
|
|
|
|
withName: true,
|
|
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
|
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
|
|
}),
|
|
|
|
}),
|
|
|
@ -224,6 +264,7 @@ export const UserList = React.memo(
|
|
|
|
</Popover.Root>
|
|
|
|
</Popover.Root>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
},
|
|
|
|
},
|
|
|
|
(prev, next) => {
|
|
|
|
(prev, next) => {
|
|
|
@ -236,10 +277,15 @@ export const UserList = React.memo(
|
|
|
|
return false;
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const nextCollaboratorSocketIds = next.collaborators.keys();
|
|
|
|
|
|
|
|
|
|
|
|
for (const [socketId, collaborator] of prev.collaborators) {
|
|
|
|
for (const [socketId, collaborator] of prev.collaborators) {
|
|
|
|
const nextCollaborator = next.collaborators.get(socketId);
|
|
|
|
const nextCollaborator = next.collaborators.get(socketId);
|
|
|
|
if (
|
|
|
|
if (
|
|
|
|
!nextCollaborator ||
|
|
|
|
!nextCollaborator ||
|
|
|
|
|
|
|
|
// this checks order of collaborators in the map is the same
|
|
|
|
|
|
|
|
// as previous render
|
|
|
|
|
|
|
|
socketId !== nextCollaboratorSocketIds.next().value ||
|
|
|
|
!isShallowEqual(
|
|
|
|
!isShallowEqual(
|
|
|
|
collaborator,
|
|
|
|
collaborator,
|
|
|
|
nextCollaborator,
|
|
|
|
nextCollaborator,
|
|
|
|