import React, { ChangeEvent, MouseEventHandler, forwardRef, useMemo, useRef, useState, } from 'react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Box, Button, Icon, IconButton, Icons, Input, Menu, MenuItem, PopOut, RectCords, Text, config, toRem, } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import FocusTrap from 'focus-trap-react'; import { Unread } from '../../../../types/matrix/room'; import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; import { NavButton, NavCategory, NavCategoryHeader, NavEmptyCenter, NavEmptyLayout, NavItem, NavItemContent, NavLink, } from '../../../components/nav'; import { encodeSearchParamValueArray, getExplorePath, getHomeCreatePath, getHomeRoomPath, getHomeSearchPath, withSearchParam, } from '../../pathUtils'; import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useHomeCreateSelected, useHomeSearchSelected, } from '../../../hooks/router/useHomeSelected'; import { useHomeRooms } from './useHomeRooms'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { VirtualTile } from '../../../components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; import { useRoomsUnread } from '../../../state/hooks/unread'; import { markAsRead } from '../../../utils/notifications'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { getRoomNotificationMode, useRoomsNotificationPreferencesContext, } from '../../../hooks/useRoomsNotificationPreferences'; import { UseStateProvider } from '../../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../../components/join-address-prompt'; import { _RoomSearchParams } from '../../paths'; import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta'; type HomeMenuProps = { requestClose: () => void; }; const HomeMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useHomeRooms(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); const handleMarkAsRead = () => { if (!unread) return; orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; return ( } radii="300" aria-disabled={!unread} > Mark as Read ); }); function HomeHeader() { const [menuAnchor, setMenuAnchor] = useState(); const handleOpenMenu: MouseEventHandler = (evt) => { const cords = evt.currentTarget.getBoundingClientRect(); setMenuAnchor((currentState) => { if (currentState) return undefined; return cords; }); }; return ( <> Home setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > setMenuAnchor(undefined)} /> } /> ); } function HomeEmpty() { const navigate = useNavigate(); return ( } title={ No Rooms } content={ You do not have any rooms yet. } options={ <> } /> ); } const factoryRoomIdByUnread = (roomToUnread: Map) => (aId: string, bId: string): number => { const aUnread = roomToUnread.get(aId); const bUnread = roomToUnread.get(bId); const aHas = (aUnread?.total ?? 0) > 0; const bHas = (bUnread?.total ?? 0) > 0; if (aHas !== bHas) return aHas ? -1 : 1; return (bUnread?.total ?? 0) - (aUnread?.total ?? 0); }; const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite'); const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority'); export function Home() { const mx = useMatrixClient(); useNavToActivePathMapper('home'); const scrollRef = useRef(null); const rooms = useHomeRooms(); const notificationPreferences = useRoomsNotificationPreferencesContext(); const [filterQuery, setFilterQuery] = useState(''); // Perf-3: only re-render when the set of rooms WITH unread changes, not on count updates const roomsWithUnreadSet = useAtomValue( useMemo( () => selectAtom( roomToUnreadAtom, (rtu) => { const s = new Set(); for (const id of rtu.keys()) s.add(id); return s; }, (a, b) => { if (a.size !== b.size) return false; for (const id of a) if (!b.has(id)) return false; return true; }, ), [], ), ); const navigate = useNavigate(); const selectedRoomId = useSelectedRoom(); const createRoomSelected = useHomeCreateSelected(); const searchSelected = useHomeSearchSelected(); const noRoomToDisplay = rooms.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const [homeRoomSort, setHomeRoomSort] = useSetting(settingsAtom, 'homeRoomSort'); const roomToUnread = useAtomValue(roomToUnreadAtom); const [sortMenuAnchor, setSortMenuAnchor] = useState(); const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => { const favs: string[] = []; const low: string[] = []; const others: string[] = []; rooms.forEach((rId) => { const room = mx.getRoom(rId); if (room?.tags?.['m.favourite']) { favs.push(rId); } else if (room?.tags?.['m.lowpriority']) { low.push(rId); } else { others.push(rId); } }); return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others }; }, [mx, rooms]); const sortedFavoriteRooms = useMemo(() => { const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID); const items = Array.from(favoriteRooms).sort( isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx), ); if (isClosed) { return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId); } return items; }, [mx, favoriteRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]); const filteredFavoriteRooms = useMemo(() => { if (!filterQuery.trim()) return sortedFavoriteRooms; const query = filterQuery.toLowerCase(); const localNames = getLocalRoomNamesContent(mx); return sortedFavoriteRooms.filter((rId) => { const localName = localNames.rooms[rId]; const matrixName = mx.getRoom(rId)?.name ?? ''; return (localName ?? matrixName).toLowerCase().includes(query); }); }, [mx, sortedFavoriteRooms, filterQuery]); const sortedLowPriorityRooms = useMemo(() => { const isClosed = closedCategories.has(LOW_PRIORITY_CATEGORY_ID); const items = Array.from(lowPriorityRooms).sort( isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx), ); if (isClosed) { return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId); } return items; }, [mx, lowPriorityRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]); const filteredLowPriorityRooms = useMemo(() => { if (!filterQuery.trim()) return sortedLowPriorityRooms; const query = filterQuery.toLowerCase(); const localNames = getLocalRoomNamesContent(mx); return sortedLowPriorityRooms.filter((rId) => { const localName = localNames.rooms[rId]; const matrixName = mx.getRoom(rId)?.name ?? ''; return (localName ?? matrixName).toLowerCase().includes(query); }); }, [mx, sortedLowPriorityRooms, filterQuery]); const sortedRooms = useMemo(() => { const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID); let comparator: (a: string, b: string) => number; if (isClosed) { comparator = factoryRoomIdByActivity(mx); } else if (homeRoomSort === 'alpha') { comparator = factoryRoomIdByAtoZ(mx); } else if (homeRoomSort === 'unread') { comparator = factoryRoomIdByUnread(roomToUnread); } else { comparator = factoryRoomIdByActivity(mx); } const items = Array.from(otherRooms).sort(comparator); if (isClosed) { return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId); } return items; }, [ mx, otherRooms, closedCategories, roomsWithUnreadSet, selectedRoomId, homeRoomSort, roomToUnread, ]); const filteredRooms = useMemo(() => { if (!filterQuery.trim()) return sortedRooms; const query = filterQuery.toLowerCase(); const localNames = getLocalRoomNamesContent(mx); return sortedRooms.filter((rId) => { const localName = localNames.rooms[rId]; const matrixName = mx.getRoom(rId)?.name ?? ''; return (localName ?? matrixName).toLowerCase().includes(query); }); }, [mx, sortedRooms, filterQuery]); const favVirtualizer = useVirtualizer({ count: filteredFavoriteRooms.length, getScrollElement: () => scrollRef.current, estimateSize: () => 38, overscan: 10, }); const virtualizer = useVirtualizer({ count: filteredRooms.length, getScrollElement: () => scrollRef.current, estimateSize: () => 38, overscan: 10, }); const lowVirtualizer = useVirtualizer({ count: filteredLowPriorityRooms.length, getScrollElement: () => scrollRef.current, estimateSize: () => 38, overscan: 10, }); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => closedCategories.has(categoryId), ); return ( {noRoomToDisplay ? ( ) : ( navigate(getHomeCreatePath())}> Create Room {(open, setOpen) => ( <> setOpen(true)}> Join with Address {open && ( setOpen(false)} onOpen={(roomIdOrAlias, viaServers, eventId) => { setOpen(false); const path = getHomeRoomPath(roomIdOrAlias, eventId); navigate( viaServers ? withSearchParam<_RoomSearchParams>(path, { viaServers: encodeSearchParamValueArray(viaServers), }) : path, ); }} /> )} )} Message Search ) => setFilterQuery(e.target.value)} placeholder="Filter rooms…" variant="Surface" size="400" radii="400" before={} after={ filterQuery ? ( setFilterQuery('')} size="400" radii="Pill" variant="Background" fill="None" aria-label="Clear filter" > ) : undefined } /> {favoriteRooms.length > 0 && ( Favorites
{favVirtualizer.getVirtualItems().map((vItem) => { const roomId = filteredFavoriteRooms[vItem.index]; const room = mx.getRoom(roomId); if (!room) return null; return ( ); })}
)} Rooms {!closedCategories.has(DEFAULT_CATEGORY_ID) && ( <> ) => { const cords = evt.currentTarget.getBoundingClientRect(); setSortMenuAnchor((current) => (current ? undefined : cords)); }} aria-label="Sort rooms" > setSortMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > { setHomeRoomSort('recent'); setSortMenuAnchor(undefined); }} size="300" after={ homeRoomSort === 'recent' ? ( ) : undefined } radii="300" > Recent Activity { setHomeRoomSort('alpha'); setSortMenuAnchor(undefined); }} size="300" after={ homeRoomSort === 'alpha' ? ( ) : undefined } radii="300" > A → Z { setHomeRoomSort('unread'); setSortMenuAnchor(undefined); }} size="300" after={ homeRoomSort === 'unread' ? ( ) : undefined } radii="300" > Unread First } /> )}
{virtualizer.getVirtualItems().map((vItem) => { const roomId = filteredRooms[vItem.index]; const room = mx.getRoom(roomId); if (!room) return null; const selected = selectedRoomId === roomId; return ( ); })}
{lowPriorityRooms.length > 0 && ( Low Priority
{lowVirtualizer.getVirtualItems().map((vItem) => { const roomId = filteredLowPriorityRooms[vItem.index]; const room = mx.getRoom(roomId); if (!room) return null; return ( ); })}
)}
)}
); }