c54cb126ff
Fix settings modal regression: Modal500 was wrapped in useModalStyle(560), forcing maxWidth 560px and squishing the two-pane Settings layout (folds size="500" is ~50rem). Restore desktop width to the folds recipe while keeping mobile fullscreen. N-series fixes: - N13 ScheduledMessagesTray header: <Box as="button"> -> folds <Button> - N28 composer char counter: drop undefined --tc-surface-low + opacity, use priority="300" and config.space token - N31 collapsible "Read more" toggle: padded <Button> -> flush inline-button pattern matching (edited) link - N41 UserPrivateNotes "Saving..." now shows a folds <Spinner> - N43 Night Light slider: add accentColor; label opacity -> priority - N44 mention-highlight Reset: bare <button> -> folds <Button> (drops undefined --border-interactive-normal); Boot button kept (TDS-only) - N45 SelectTheme trigger variant -> Secondary to match SettingsSelect - N49 RoomInsights StatTile emoji -> folds <Icon> (Photo/VideoCamera/ Headphone/File) - N54/N57 PiP overlay badges + fullscreen button: token discipline (config.radii/space, folds Text); dark scrim kept for video legibility - N60 knock badge: match Pinned Messages pattern (no wrapper div, toRem insets, no hardcoded size overrides) - N62 unverified-device banner: 3px left-accent -> standard border via color.Warning.ContainerLine; drop opacity hacks - N65 Edit History: real "Load more" pagination (accumulate next_batch, de-dupe by id, re-sort by ts) replacing passive text - N66 search date fields: raw <input type="date"> -> folds <Input> - N67 SeasonalEffect z-index 9999 -> 9997 (below Night Light + modals) - N73 Pending Requests header uses css.MembersGroupLabel - N74 remove raw em-sized emoji <span> in RoomNavItem name - N85/N86 RemindMeDialog: <Box role="dialog"> -> folds <Dialog>; preset MenuItems -> Buttons (fixes invalid menuitem-in-dialog ARIA) Document deliberate WON'T FIX rationale for N9, N51, N61, N71, N75, N77. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
558 lines
20 KiB
TypeScript
558 lines
20 KiB
TypeScript
import React, {
|
|
ChangeEventHandler,
|
|
MouseEventHandler,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
Avatar,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
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 { Membership } from '../../../types/matrix/room';
|
|
|
|
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 {
|
|
readPowerLevel,
|
|
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, PresenceRingAvatar } from '../../components/presence';
|
|
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
|
|
|
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={
|
|
<AvatarDecoration userId={member.userId}>
|
|
<PresenceRingAvatar userId={member.userId}>
|
|
<Avatar size="200">
|
|
<UserAvatar
|
|
userId={member.userId}
|
|
src={avatarUrl ?? undefined}
|
|
alt={name}
|
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
|
/>
|
|
</Avatar>
|
|
</PresenceRingAvatar>
|
|
</AvatarDecoration>
|
|
}
|
|
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 myUserId = mx.getUserId();
|
|
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
|
|
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
|
|
const canApproveKnock = myPowerLevel >= invitePowerLevel;
|
|
const knockMembers = useMemo(
|
|
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
|
|
[room, canApproveKnock],
|
|
);
|
|
|
|
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>
|
|
|
|
{knockMembers.length > 0 && (
|
|
<Box direction="Column" gap="100">
|
|
<Text className={css.MembersGroupLabel} size="L400">
|
|
Pending Requests
|
|
</Text>
|
|
{knockMembers.map((knockMember) => {
|
|
const knockName =
|
|
getMemberDisplayName(room, knockMember.userId) ??
|
|
getMxIdLocalPart(knockMember.userId) ??
|
|
knockMember.userId;
|
|
const knockAvatarMxc = knockMember.getMxcAvatarUrl();
|
|
const knockAvatarUrl = knockAvatarMxc
|
|
? mx.mxcUrlToHttp(
|
|
knockAvatarMxc,
|
|
100,
|
|
100,
|
|
'crop',
|
|
undefined,
|
|
false,
|
|
useAuthentication,
|
|
)
|
|
: undefined;
|
|
return (
|
|
<Box
|
|
key={knockMember.userId}
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{ padding: `0 ${config.space.S200}` }}
|
|
>
|
|
<AvatarDecoration userId={knockMember.userId}>
|
|
<PresenceRingAvatar userId={knockMember.userId}>
|
|
<Avatar size="200">
|
|
<UserAvatar
|
|
userId={knockMember.userId}
|
|
src={knockAvatarUrl ?? undefined}
|
|
alt={knockName}
|
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
|
/>
|
|
</Avatar>
|
|
</PresenceRingAvatar>
|
|
</AvatarDecoration>
|
|
<Box grow="Yes" direction="Column">
|
|
<Text size="T400" truncate>
|
|
{knockName}
|
|
</Text>
|
|
</Box>
|
|
<Box shrink="No" gap="100">
|
|
<Button
|
|
size="300"
|
|
variant="Success"
|
|
radii="300"
|
|
fill="Soft"
|
|
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
|
>
|
|
<Text size="B300">Approve</Text>
|
|
</Button>
|
|
<Button
|
|
size="300"
|
|
variant="Critical"
|
|
radii="300"
|
|
fill="Soft"
|
|
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
|
>
|
|
<Text size="B300">Deny</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
|
|
{!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>
|
|
);
|
|
}
|