import React, { ChangeEventHandler, MouseEventHandler, useCallback, useMemo, useRef, useState, } from 'react'; import { Avatar, AvatarFallback, AvatarImage, Badge, Box, Chip, ContainerColor, Header, Icon, IconButton, Icons, Input, Menu, MenuItem, PopOut, 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 millify from 'millify'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import { openInviteUser, openProfileViewer } from '../../../client/action/navigation'; import * as css from './MembersDrawer.css'; import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getIntersectionObserverEntry, useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; import { Membership } from '../../../types/matrix/room'; import { UseStateProvider } from '../../components/UseStateProvider'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch'; import { useDebounce } from '../../hooks/useDebounce'; import colorMXID from '../../../util/colorMXID'; import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags'; import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers'; import { TypingIndicator } from '../../components/typing-indicator'; import { getMemberDisplayName } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; 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: 100, matchOptions: { contain: true, }, }; const getMemberItemStr = (m: RoomMember) => [m.name, m.userId]; type MembersDrawerProps = { room: Room; }; export function MembersDrawer({ room }: MembersDrawerProps) { const mx = useMatrixClient(); const scrollRef = useRef(null); const searchInputRef = useRef(null); const scrollTopAnchorRef = useRef(null); const members = useRoomMembers(mx, room.roomId); const getPowerLevelTag = usePowerLevelTags(); const fetchingMembers = members.length < room.getJoinedMemberCount(); const membershipFilterMenu = useMembershipFilterMenu(); const sortFilterMenu = useSortFilterMenu(); const [filter, setFilter] = useState({ membershipFilter: membershipFilterMenu[0], sortFilter: sortFilterMenu[0], }); const [onTop, setOnTop] = useState(true); const typingMembers = useAtomValue( useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room]) ); const filteredMembers = useMemo( () => members .filter(filter.membershipFilter.filterFn) .sort(filter.sortFilter.filterFn) .sort((a, b) => b.powerLevel - a.powerLevel), [members, filter] ); const [result, search, resetSearch] = useAsyncSearch( filteredMembers, getMemberItemStr, 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, }); useIntersectionObserver( useCallback((intersectionEntries) => { if (!scrollTopAnchorRef.current) return; const entry = getIntersectionObserverEntry(scrollTopAnchorRef.current, intersectionEntries); if (entry) setOnTop(entry.isIntersecting); }, []), useCallback(() => ({ root: scrollRef.current }), []), useCallback(() => scrollTopAnchorRef.current, []) ); 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(), { precision: 1 })} Members`} Invite Member } > {(triggerRef) => ( openInviteUser(room.roomId)} > )}
{(open, setOpen) => ( setOpen(false), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', }} > {membershipFilterMenu.map((menuItem) => ( { setFilter((f) => ({ ...f, membershipFilter: menuItem })); setOpen(false); }} > {menuItem.name} ))} } > {(anchorRef) => ( setOpen(!open)} variant={filter.membershipFilter.color} size="400" radii="300" before={} > {filter.membershipFilter.name} )} )} {(open, setOpen) => ( setOpen(false), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', }} > {sortFilterMenu.map((menuItem) => ( { setFilter((f) => ({ ...f, sortFilter: menuItem })); setOpen(false); }} > {menuItem.name} ))} } > {(anchorRef) => ( setOpen(!open)} variant="Background" size="400" radii="300" after={} > {filter.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' }`} ) } /> {!onTop && ( virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" outlined size="300" aria-label="Scroll to Top" > )} {!fetchingMembers && !result && processMembers.length === 0 && ( {`No "${filter.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 avatarUrl = member.getAvatarUrl( mx.baseUrl, 100, 100, 'crop', undefined, false ); return ( {avatarUrl ? ( ) : ( {name[0]} )} } after={ typingMembers.find((tm) => tm.userId === member.userId) && ( ) } > {name} ); })}
{fetchingMembers && ( )}
); }