8c0383ab7f
The banner saying 'E2EE · Shield = verified identity' was cluttering the top of the member list. Hovering the lock icon in the room header already communicates encryption status, and tooltips on the shield badges already explain what verified means. Removed the entire block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
import React, {
|
|
ChangeEventHandler,
|
|
MouseEventHandler,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
Avatar,
|
|
Badge,
|
|
Box,
|
|
Chip,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Input,
|
|
MenuItem,
|
|
PopOut,
|
|
RectCords,
|
|
Scroll,
|
|
Spinner,
|
|
Text,
|
|
Tooltip,
|
|
TooltipProvider,
|
|
config,
|
|
} from 'folds';
|
|
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
|
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';
|
|
import {
|
|
SearchItemStrGetter,
|
|
UseAsyncSearchOptions,
|
|
useAsyncSearch,
|
|
} from '../../hooks/useAsyncSearch';
|
|
import { useDebounce } from '../../hooks/useDebounce';
|
|
import { TypingIndicator } from '../../components/typing-indicator';
|
|
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
|
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
|
import { useSetSetting, useSetting } from '../../state/hooks/settings';
|
|
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';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
|
|
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
|
|
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
|
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
|
|
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
|
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
|
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
|
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
|
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
|
import { useUserPresence } from '../../hooks/useUserPresence';
|
|
import { PresenceBadge } from '../../components/presence';
|
|
|
|
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"
|
|
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;
|
|
showEncryption?: boolean;
|
|
};
|
|
|
|
function MemberItem({
|
|
mx,
|
|
useAuthentication,
|
|
room,
|
|
member,
|
|
onClick,
|
|
pressed,
|
|
typing,
|
|
showEncryption,
|
|
}: 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;
|
|
const presence = useUserPresence(member.userId);
|
|
|
|
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={
|
|
<>
|
|
{presence && (
|
|
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
|
|
)}
|
|
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
|
{typing && (
|
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
|
<TypingIndicator size="300" />
|
|
</Badge>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T400" truncate>
|
|
{name}
|
|
</Text>
|
|
{presence?.status && (
|
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
|
{presence.status}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</MenuItem>
|
|
);
|
|
}
|
|
|
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
|
limit: 1000,
|
|
matchOptions: {
|
|
contain: true,
|
|
},
|
|
};
|
|
|
|
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
|
|
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
|
|
getMemberSearchStr(m, query, mxIdToName);
|
|
|
|
type MembersDrawerProps = {
|
|
room: Room;
|
|
members: RoomMember[];
|
|
};
|
|
export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const crossSigningActive = useCrossSigningActive();
|
|
const showEncryption = crossSigningActive;
|
|
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
|
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
|
|
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
|
const powerLevels = usePowerLevelsContext();
|
|
const creators = useRoomCreators(room);
|
|
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
|
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
|
|
|
|
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
|
const space = useSpaceOptionally();
|
|
const openProfileUserId = useUserRoomProfileState()?.userId;
|
|
|
|
const membershipFilterMenu = useMembershipFilterMenu();
|
|
const sortFilterMenu = useMemberSortMenu();
|
|
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);
|
|
|
|
const typingMembers = useRoomTypingMember(room.roomId);
|
|
|
|
const filteredMembers = useMemo(
|
|
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
|
|
[members, membershipFilter, memberSort, memberPowerSort],
|
|
);
|
|
|
|
const [result, search, resetSearch] = useAsyncSearch(
|
|
filteredMembers,
|
|
getRoomMemberStr,
|
|
SEARCH_OPTIONS,
|
|
);
|
|
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
|
|
|
|
const processMembers = result ? result.items : filteredMembers;
|
|
|
|
const PLTagOrRoomMember = useFlattenPowerTagMembers(processMembers, getPowerTag);
|
|
|
|
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');
|
|
if (!userId) return;
|
|
openUserRoomProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect(), 'Left');
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
className={classNames(css.MembersDrawer, ContainerColor({ variant: 'Background' }))}
|
|
shrink="No"
|
|
direction="Column"
|
|
>
|
|
<MemberDrawerHeader room={room} />
|
|
<Box className={css.MemberDrawerContentBase} grow="Yes">
|
|
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
|
|
<Box className={css.MemberDrawerContent} direction="Column" gap="200">
|
|
<Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
|
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
|
<UseStateProvider initial={undefined}>
|
|
{(anchor: RectCords | undefined, setAnchor) => (
|
|
<PopOut
|
|
anchor={anchor}
|
|
position="Bottom"
|
|
align="Start"
|
|
offset={4}
|
|
content={
|
|
<MembershipFilterMenu
|
|
selected={membershipFilterIndex}
|
|
onSelect={setMembershipFilterIndex}
|
|
requestClose={() => setAnchor(undefined)}
|
|
/>
|
|
}
|
|
>
|
|
<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>
|
|
</PopOut>
|
|
)}
|
|
</UseStateProvider>
|
|
<UseStateProvider initial={undefined}>
|
|
{(anchor: RectCords | undefined, setAnchor) => (
|
|
<PopOut
|
|
anchor={anchor}
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
content={
|
|
<MemberSortMenu
|
|
selected={sortFilterIndex}
|
|
onSelect={setSortFilterIndex}
|
|
requestClose={() => setAnchor(undefined)}
|
|
/>
|
|
}
|
|
>
|
|
<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>
|
|
</PopOut>
|
|
)}
|
|
</UseStateProvider>
|
|
</Box>
|
|
<Box direction="Column" gap="100">
|
|
<Input
|
|
ref={searchInputRef}
|
|
onChange={handleSearchChange}
|
|
style={{ paddingRight: config.space.S200 }}
|
|
placeholder="Type name..."
|
|
variant="Surface"
|
|
size="400"
|
|
radii="400"
|
|
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} />}
|
|
>
|
|
<Text size="B300">{`${result.items.length || 'No'} ${
|
|
result.items.length === 1 ? 'Result' : 'Results'
|
|
}`}</Text>
|
|
</Chip>
|
|
)
|
|
}
|
|
/>
|
|
</Box>
|
|
</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>
|
|
|
|
{!fetchingMembers && !result && processMembers.length === 0 && (
|
|
<Text style={{ padding: config.space.S300 }} align="Center">
|
|
{`No "${membershipFilter.name}" Members`}
|
|
</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)}
|
|
size="L400"
|
|
>
|
|
{tagOrMember.name}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
transform: `translateY(${vItem.start}px)`,
|
|
}}
|
|
className={css.DrawerVirtualItem}
|
|
data-index={vItem.index}
|
|
key={`${room.roomId}-${tagOrMember.userId}`}
|
|
ref={virtualizer.measureElement}
|
|
>
|
|
<MemberItem
|
|
mx={mx}
|
|
useAuthentication={useAuthentication}
|
|
room={room}
|
|
member={tagOrMember}
|
|
onClick={handleMemberClick}
|
|
pressed={openProfileUserId === tagOrMember.userId}
|
|
typing={typingMembers.some(
|
|
(receipt) => receipt.userId === tagOrMember.userId,
|
|
)}
|
|
showEncryption={showEncryption}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Box>
|
|
|
|
{fetchingMembers && (
|
|
<Box justifyContent="Center">
|
|
<Spinner />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Scroll>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|