import React, { ChangeEventHandler, MouseEventHandler, useCallback, useMemo, useRef, useState, } from 'react'; import { Avatar, Badge, Box, Chip, ContainerColor, Header, Icon, IconButton, Icons, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Spinner, Text, Tooltip, TooltipProvider, config, } from 'folds'; import { Room, RoomMember } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; import { openProfileViewer } from '../../../client/action/navigation'; import * as css from './MembersDrawer.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { Membership } from '../../../types/matrix/room'; import { UseStateProvider } from '../../components/UseStateProvider'; import { SearchItemStrGetter, UseAsyncSearchOptions, useAsyncSearch, } from '../../hooks/useAsyncSearch'; import { useDebounce } from '../../hooks/useDebounce'; import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags'; 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 { stopPropagation } from '../../utils/keyboard'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; export const MembershipFilters = { filterJoined: (m: RoomMember) => m.membership === Membership.Join, filterInvited: (m: RoomMember) => m.membership === Membership.Invite, filterLeaved: (m: RoomMember) => m.membership === Membership.Leave && m.events.member?.getStateKey() === m.events.member?.getSender(), filterKicked: (m: RoomMember) => m.membership === Membership.Leave && m.events.member?.getStateKey() !== m.events.member?.getSender(), filterBanned: (m: RoomMember) => m.membership === Membership.Ban, }; export type MembershipFilterFn = (m: RoomMember) => boolean; export type MembershipFilter = { name: string; filterFn: MembershipFilterFn; color: ContainerColor; }; const useMembershipFilterMenu = (): MembershipFilter[] => useMemo( () => [ { name: 'Joined', filterFn: MembershipFilters.filterJoined, color: 'Background', }, { name: 'Invited', filterFn: MembershipFilters.filterInvited, color: 'Success', }, { name: 'Left', filterFn: MembershipFilters.filterLeaved, color: 'Secondary', }, { name: 'Kicked', filterFn: MembershipFilters.filterKicked, color: 'Warning', }, { name: 'Banned', filterFn: MembershipFilters.filterBanned, color: 'Critical', }, ], [] ); export const SortFilters = { filterAscending: (a: RoomMember, b: RoomMember) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1, filterDescending: (a: RoomMember, b: RoomMember) => a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1, filterNewestFirst: (a: RoomMember, b: RoomMember) => (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0), filterOldest: (a: RoomMember, b: RoomMember) => (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0), }; export type SortFilterFn = (a: RoomMember, b: RoomMember) => number; export type SortFilter = { name: string; filterFn: SortFilterFn; }; const useSortFilterMenu = (): SortFilter[] => useMemo( () => [ { name: 'A to Z', filterFn: SortFilters.filterAscending, }, { name: 'Z to A', filterFn: SortFilters.filterDescending, }, { name: 'Newest', filterFn: SortFilters.filterNewestFirst, }, { name: 'Oldest', filterFn: SortFilters.filterOldest, }, ], [] ); export type MembersFilterOptions = { membershipFilter: MembershipFilter; sortFilter: SortFilter; }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, matchOptions: { contain: true, }, }; const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId; const getRoomMemberStr: SearchItemStrGetter = (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 scrollRef = useRef(null); const searchInputRef = useRef(null); const scrollTopAnchorRef = useRef(null); const getPowerLevelTag = usePowerLevelTags(); const fetchingMembers = members.length < room.getJoinedMemberCount(); const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const membershipFilterMenu = useMembershipFilterMenu(); const sortFilterMenu = useSortFilterMenu(); const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex'); const [membershipFilterIndex, setMembershipFilterIndex] = useState(0); const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0]; const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0]; const typingMembers = useRoomTypingMember(room.roomId); const filteredMembers = useMemo( () => members .filter(membershipFilter.filterFn) .sort(sortFilter.filterFn) .sort((a, b) => b.powerLevel - a.powerLevel), [members, membershipFilter, sortFilter] ); 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 = useMemo(() => { let prevTag: PowerLevelTag | undefined; const tagOrMember: Array = []; processMembers.forEach((m) => { const plTag = getPowerLevelTag(m.powerLevel); if (plTag !== prevTag) { prevTag = plTag; tagOrMember.push(plTag); } tagOrMember.push(m); }); return tagOrMember; }, [processMembers, getPowerLevelTag]); const virtualizer = useVirtualizer({ count: PLTagOrRoomMember.length, getScrollElement: () => scrollRef.current, estimateSize: () => 40, overscan: 10, }); const handleSearchChange: ChangeEventHandler = useDebounce( useCallback( (evt) => { if (evt.target.value) search(evt.target.value); else resetSearch(); }, [search, resetSearch] ), { wait: 200 } ); const getName = (member: RoomMember) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; const handleMemberClick: MouseEventHandler = (evt) => { const btn = evt.currentTarget as HTMLButtonElement; const userId = btn.getAttribute('data-user-id'); openProfileViewer(userId, room.roomId); }; return (
{`${millify(room.getJoinedMemberCount())} Members`} Close } > {(triggerRef) => ( setPeopleDrawer(false)} > )}
{(anchor: RectCords | undefined, setAnchor) => ( setAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > {membershipFilterMenu.map((menuItem, index) => ( { setMembershipFilterIndex(index); setAnchor(undefined); }} > {menuItem.name} ))} } > setAnchor( evt.currentTarget.getBoundingClientRect() )) as MouseEventHandler } variant={membershipFilter.color} size="400" radii="300" before={} > {membershipFilter.name} )} {(anchor: RectCords | undefined, setAnchor) => ( setAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > {sortFilterMenu.map((menuItem, index) => ( { setSortFilterIndex(index); setAnchor(undefined); }} > {menuItem.name} ))} } > setAnchor( evt.currentTarget.getBoundingClientRect() )) as MouseEventHandler } variant="Background" size="400" radii="300" after={} > {sortFilter.name} )} } after={ result && ( 0 ? 'Success' : 'Critical'} size="400" radii="Pill" aria-pressed onClick={() => { if (searchInputRef.current) { searchInputRef.current.value = ''; searchInputRef.current.focus(); } resetSearch(); }} after={} > {`${result.items.length || 'No'} ${ result.items.length === 1 ? 'Result' : 'Results' }`} ) } /> virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" outlined size="300" aria-label="Scroll to Top" > {!fetchingMembers && !result && processMembers.length === 0 && ( {`No "${membershipFilter.name}" Members`} )}
{virtualizer.getVirtualItems().map((vItem) => { const tagOrMember = PLTagOrRoomMember[vItem.index]; if (!('userId' in tagOrMember)) { return ( {tagOrMember.name} ); } const member = tagOrMember; const name = getName(member); const avatarMxcUrl = member.getMxcAvatarUrl(); const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp( avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication ) : undefined; return ( } /> } after={ typingMembers.find((receipt) => receipt.userId === member.userId) && ( ) } > {name} ); })}
{fetchingMembers && ( )}
); }