Files
cinny/src/app/pages/client/home/Home.tsx
T
jared d46b91b1b8 feat(rooms): Mark as Unread (MSC2867) + Low Priority rooms
Two Matrix protocol gaps (Phase A), gate-green (683 tests):
- Mark as Unread: m.marked_unread room account data (+ com.famedly.marked_unread
  fallback), a new markedUnreadAtom binder that seeds from account data and
  clears on our own read receipt (MSC2867). RoomNavItem gains Mark as Unread /
  Read menu items and lights the row dot for a marked room. Tested.
- Low Priority: m.lowpriority room tag mirroring favourites — a context-menu
  toggle (mutually exclusive with Favorite) and a collapsed Low Priority
  category sorted to the bottom of the Home room list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:04:47 -04:00

717 lines
26 KiB
TypeScript

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<HTMLDivElement, HomeMenuProps>(({ 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 (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function HomeHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text as="h2" size="H4" truncate>
Home
</Text>
</Box>
<Box>
<IconButton
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
variant="Background"
onClick={handleOpenMenu}
aria-label="Home options"
>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function HomeEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Hash} />}
title={
<Text size="H5" align="Center">
No Rooms
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any rooms yet.
</Text>
}
options={
<>
<Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
<Text size="B300" truncate>
Create Room
</Text>
</Button>
<Button
onClick={() => navigate(getExplorePath())}
variant="Secondary"
fill="Soft"
size="300"
>
<Text size="B300" truncate>
Explore Community Rooms
</Text>
</Button>
</>
}
/>
</NavEmptyCenter>
);
}
const factoryRoomIdByUnread =
(roomToUnread: Map<string, Unread>) =>
(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<HTMLDivElement>(null);
const rooms = useHomeRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const [filterQuery, setFilterQuery] = useState<string>('');
// 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<string>();
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<RectCords>();
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 (
<PageNav>
<HomeHeader />
{noRoomToDisplay ? (
<HomeEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
<NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Room
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<UseStateProvider initial={false}>
{(open, setOpen) => (
<>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => setOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
{open && (
<JoinAddressPrompt
onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false);
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path,
);
}}
/>
)}
</>
)}
</UseStateProvider>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getHomeSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<Box
grow="Yes"
style={{ padding: `0 ${config.space.S200}`, paddingBottom: config.space.S100 }}
direction="Column"
gap="100"
>
<Input
value={filterQuery}
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterQuery(e.target.value)}
placeholder="Filter rooms…"
variant="Surface"
size="400"
radii="400"
before={<Icon size="50" src={Icons.Search} />}
after={
filterQuery ? (
<IconButton
onClick={() => setFilterQuery('')}
size="400"
radii="Pill"
variant="Background"
fill="None"
aria-label="Clear filter"
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
) : undefined
}
/>
</Box>
</NavCategory>
{favoriteRooms.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(FAVORITES_CATEGORY_ID)}
data-category-id={FAVORITES_CATEGORY_ID}
onClick={handleCategoryClick}
>
Favorites
</RoomNavCategoryButton>
</NavCategoryHeader>
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
{favVirtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredFavoriteRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={favVirtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
)}
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
>
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
{lowPriorityRooms.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(LOW_PRIORITY_CATEGORY_ID)}
data-category-id={LOW_PRIORITY_CATEGORY_ID}
onClick={handleCategoryClick}
>
Low Priority
</RoomNavCategoryButton>
</NavCategoryHeader>
<div style={{ position: 'relative', height: lowVirtualizer.getTotalSize() }}>
{lowVirtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredLowPriorityRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={lowVirtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
)}
</Box>
</PageNavContent>
)}
</PageNav>
);
}