Files
cinny/src/app/features/room/MembersDrawer.tsx
T

444 lines
16 KiB
TypeScript
Raw Normal View History

2023-06-22 09:14:50 +10:00
import React, {
ChangeEventHandler,
MouseEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import {
Avatar,
2023-10-06 13:44:06 +11:00
Badge,
2023-06-22 09:14:50 +10:00
Box,
Chip,
Header,
Icon,
IconButton,
Icons,
Input,
MenuItem,
PopOut,
RectCords,
2023-06-22 09:14:50 +10:00
Scroll,
Spinner,
Text,
Tooltip,
TooltipProvider,
config,
} from 'folds';
2025-08-12 19:42:30 +05:30
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
2023-06-22 09:14:50 +10:00
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
import * as css from './MembersDrawer.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UseStateProvider } from '../../components/UseStateProvider';
2023-10-19 17:43:16 +11:00
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../hooks/useAsyncSearch';
2023-06-22 09:14:50 +10:00
import { useDebounce } from '../../hooks/useDebounce';
2023-10-06 13:44:06 +11:00
import { TypingIndicator } from '../../components/typing-indicator';
2023-10-19 17:43:16 +11:00
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
2023-10-06 13:44:06 +11:00
import { getMxIdLocalPart } from '../../utils/matrix';
import { useSetSetting, useSetting } from '../../state/hooks/settings';
2023-10-19 17:43:16 +11:00
import { settingsAtom } from '../../state/settings';
import { millify } from '../../plugins/millify';
import { ScrollTopContainer } from '../../components/scroll-top-container';
import { UserAvatar } from '../../components/user-avatar';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
2024-09-09 18:45:20 +10:00
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
2025-08-12 19:42:30 +05:30
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../components/MemberSortMenu';
2025-08-09 17:46:10 +05:30
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
2025-08-12 19:42:30 +05:30
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
type MemberDrawerHeaderProps = {
room: Room;
};
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
return (
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
{`${millify(room.getJoinedMemberCount())} Members`}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close member list"
2025-08-12 19:42:30 +05:30
onClick={() => setPeopleDrawer(false)}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
);
}
type MemberItemProps = {
mx: MatrixClient;
useAuthentication: boolean;
room: Room;
member: RoomMember;
onClick: MouseEventHandler<HTMLButtonElement>;
pressed?: boolean;
typing?: boolean;
};
function MemberItem({
mx,
useAuthentication,
room,
member,
onClick,
pressed,
typing,
}: MemberItemProps) {
const name =
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
: undefined;
return (
<MenuItem
style={{ padding: `0 ${config.space.S200}` }}
aria-pressed={pressed}
data-user-id={member.userId}
variant="Background"
radii="400"
onClick={onClick}
before={
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
after={
typing && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" />
</Badge>
)
}
>
<Box grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
</Box>
</MenuItem>
);
}
2023-06-22 09:14:50 +10:00
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
2025-02-21 19:14:38 +11:00
limit: 1000,
2023-06-22 09:14:50 +10:00
matchOptions: {
contain: true,
},
};
2023-10-19 17:43:16 +11:00
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
getMemberSearchStr(m, query, mxIdToName);
2023-06-22 09:14:50 +10:00
type MembersDrawerProps = {
room: Room;
members: RoomMember[];
2023-06-22 09:14:50 +10:00
};
export function MembersDrawer({ room, members }: MembersDrawerProps) {
2023-06-22 09:14:50 +10:00
const mx = useMatrixClient();
2024-09-09 18:45:20 +10:00
const useAuthentication = useMediaAuthentication();
2023-06-22 09:14:50 +10:00
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const powerLevels = usePowerLevelsContext();
2025-08-12 19:42:30 +05:30
const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
2025-08-12 19:42:30 +05:30
2023-06-22 09:14:50 +10:00
const fetchingMembers = members.length < room.getJoinedMemberCount();
2025-08-09 17:46:10 +05:30
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const openProfileUserId = useUserRoomProfileState()?.userId;
2023-06-22 09:14:50 +10:00
const membershipFilterMenu = useMembershipFilterMenu();
const sortFilterMenu = useMemberSortMenu();
2023-10-19 17:43:16 +11:00
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
2023-10-19 17:43:16 +11:00
const typingMembers = useRoomTypingMember(room.roomId);
2023-10-06 13:44:06 +11:00
2023-06-22 09:14:50 +10:00
const filteredMembers = useMemo(
2025-08-12 19:42:30 +05:30
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
[members, membershipFilter, memberSort, memberPowerSort]
2023-06-22 09:14:50 +10:00
);
const [result, search, resetSearch] = useAsyncSearch(
filteredMembers,
2023-10-19 17:43:16 +11:00
getRoomMemberStr,
2023-06-22 09:14:50 +10:00
SEARCH_OPTIONS
);
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
const processMembers = result ? result.items : filteredMembers;
2025-08-12 19:42:30 +05:30
const PLTagOrRoomMember = useFlattenPowerTagMembers(processMembers, getPowerTag);
2023-06-22 09:14:50 +10:00
const virtualizer = useVirtualizer({
count: PLTagOrRoomMember.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 40,
overscan: 10,
});
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
useCallback(
(evt) => {
if (evt.target.value) search(evt.target.value);
else resetSearch();
},
[search, resetSearch]
),
{ wait: 200 }
);
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
2025-08-09 17:46:10 +05:30
if (!userId) return;
openUserRoomProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect(), 'Left');
2023-06-22 09:14:50 +10:00
};
return (
2025-08-12 19:42:30 +05:30
<Box
className={classNames(css.MembersDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<MemberDrawerHeader room={room} />
2023-06-22 09:14:50 +10:00
<Box className={css.MemberDrawerContentBase} grow="Yes">
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
2023-06-25 08:40:48 +05:30
<Box className={css.MemberDrawerContent} direction="Column" gap="200">
<Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
2023-06-23 09:46:04 +10:00
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
2023-06-22 09:14:50 +10:00
<PopOut
anchor={anchor}
2023-06-22 09:14:50 +10:00
position="Bottom"
align="Start"
2023-06-25 08:40:48 +05:30
offset={4}
2023-06-22 09:14:50 +10:00
content={
<MembershipFilterMenu
selected={membershipFilterIndex}
onSelect={setMembershipFilterIndex}
requestClose={() => setAnchor(undefined)}
/>
2023-06-22 09:14:50 +10:00
}
>
<Chip
onClick={
((evt) =>
setAnchor(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
variant="Background"
size="400"
radii="300"
before={<Icon src={Icons.Filter} size="50" />}
>
<Text size="T200">{membershipFilter.name}</Text>
</Chip>
2023-06-22 09:14:50 +10:00
</PopOut>
)}
</UseStateProvider>
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
2023-06-22 09:14:50 +10:00
<PopOut
anchor={anchor}
2023-06-22 09:14:50 +10:00
position="Bottom"
2023-06-23 09:46:04 +10:00
align="End"
2023-06-25 08:40:48 +05:30
offset={4}
2023-06-22 09:14:50 +10:00
content={
<MemberSortMenu
selected={sortFilterIndex}
onSelect={setSortFilterIndex}
requestClose={() => setAnchor(undefined)}
/>
2023-06-22 09:14:50 +10:00
}
>
<Chip
onClick={
((evt) =>
setAnchor(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
variant="Background"
size="400"
radii="300"
after={<Icon src={Icons.Sort} size="50" />}
>
<Text size="T200">{memberSort.name}</Text>
</Chip>
2023-06-22 09:14:50 +10:00
</PopOut>
)}
</UseStateProvider>
</Box>
2023-06-23 09:46:04 +10:00
<Box direction="Column" gap="100">
<Input
ref={searchInputRef}
onChange={handleSearchChange}
style={{ paddingRight: config.space.S200 }}
placeholder="Type name..."
variant="Surface"
2023-06-25 08:40:48 +05:30
size="400"
radii="400"
2023-06-23 09:46:04 +10:00
before={<Icon size="50" src={Icons.Search} />}
after={
result && (
<Chip
variant={result.items.length > 0 ? 'Success' : 'Critical'}
size="400"
radii="Pill"
aria-pressed
onClick={() => {
if (searchInputRef.current) {
searchInputRef.current.value = '';
searchInputRef.current.focus();
}
resetSearch();
}}
after={<Icon size="50" src={Icons.Cross} />}
>
2025-02-21 19:14:38 +11:00
<Text size="B300">{`${result.items.length || 'No'} ${
result.items.length === 1 ? 'Result' : 'Results'
}`}</Text>
2023-06-23 09:46:04 +10:00
</Chip>
)
}
/>
</Box>
2023-06-22 09:14:50 +10:00
</Box>
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
<IconButton
onClick={() => virtualizer.scrollToOffset(0)}
aria-label="Scroll to top"
variant="Surface"
radii="Pill"
outlined
size="300"
>
<Icon src={Icons.ChevronTop} size="300" />
</IconButton>
</ScrollTopContainer>
2023-06-22 09:14:50 +10:00
{!fetchingMembers && !result && processMembers.length === 0 && (
<Text style={{ padding: config.space.S300 }} align="Center">
2023-10-19 17:43:16 +11:00
{`No "${membershipFilter.name}" Members`}
2023-06-22 09:14:50 +10:00
</Text>
)}
<Box className={css.MembersGroup} direction="Column" gap="100">
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const tagOrMember = PLTagOrRoomMember[vItem.index];
if (!('userId' in tagOrMember)) {
return (
<Text
style={{
transform: `translateY(${vItem.start}px)`,
}}
data-index={vItem.index}
ref={virtualizer.measureElement}
key={`${room.roomId}-${vItem.index}`}
className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
2023-06-23 09:46:04 +10:00
size="L400"
2023-06-22 09:14:50 +10:00
>
{tagOrMember.name}
</Text>
);
}
return (
2025-08-12 19:42:30 +05:30
<div
2023-06-22 09:14:50 +10:00
style={{
transform: `translateY(${vItem.start}px)`,
}}
2025-08-12 19:42:30 +05:30
className={css.DrawerVirtualItem}
2023-06-22 09:14:50 +10:00
data-index={vItem.index}
2025-08-12 19:42:30 +05:30
key={`${room.roomId}-${tagOrMember.userId}`}
2023-06-22 09:14:50 +10:00
ref={virtualizer.measureElement}
>
2025-08-12 19:42:30 +05:30
<MemberItem
mx={mx}
useAuthentication={useAuthentication}
room={room}
member={tagOrMember}
onClick={handleMemberClick}
pressed={openProfileUserId === tagOrMember.userId}
typing={typingMembers.some(
(receipt) => receipt.userId === tagOrMember.userId
)}
/>
</div>
2023-06-22 09:14:50 +10:00
);
})}
</div>
</Box>
{fetchingMembers && (
<Box justifyContent="Center">
<Spinner />
</Box>
)}
</Box>
</Scroll>
</Box>
</Box>
);
}