CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 11m15s
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.
Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
810 lines
30 KiB
TypeScript
810 lines
30 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 {
|
|
factoryRoomIdByActivity,
|
|
factoryRoomIdByAtoZ,
|
|
factoryRoomIdByUnread,
|
|
} 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,
|
|
RoomsNotificationPreferences,
|
|
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';
|
|
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
|
|
import { RoomSection, deriveRoomSections } from '../../../utils/roomSections';
|
|
|
|
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,
|
|
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 DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
|
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
|
type HomeCustomSectionProps = {
|
|
section: RoomSection;
|
|
closed: boolean;
|
|
onCategoryClick: MouseEventHandler<HTMLButtonElement>;
|
|
filterQuery: string;
|
|
selectedRoomId?: string;
|
|
roomsWithUnreadSet: Set<string>;
|
|
notificationPreferences: RoomsNotificationPreferences;
|
|
};
|
|
/** [Gitea #108] One collapsible u.* section; mirrors the Favorites block. */
|
|
function HomeCustomSection({
|
|
section,
|
|
closed,
|
|
onCategoryClick,
|
|
filterQuery,
|
|
selectedRoomId,
|
|
roomsWithUnreadSet,
|
|
notificationPreferences,
|
|
}: HomeCustomSectionProps) {
|
|
const mx = useMatrixClient();
|
|
const categoryId = makeNavCategoryId('home', section.tag);
|
|
const items = useMemo(() => {
|
|
// Open: the tag's own order. Closed: only unread/selected, by activity.
|
|
const base = closed
|
|
? [...section.rooms]
|
|
.sort(factoryRoomIdByActivity(mx))
|
|
.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId)
|
|
: section.rooms;
|
|
if (!filterQuery.trim()) return base;
|
|
const query = filterQuery.toLowerCase();
|
|
const localNames = getLocalRoomNamesContent(mx);
|
|
return base.filter((rId) => {
|
|
const localName = localNames.rooms[rId];
|
|
const matrixName = mx.getRoom(rId)?.name ?? '';
|
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
|
});
|
|
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
|
// Sections are user-curated and small, so they render plainly — no
|
|
// virtualizer per section (a late-mounted one never measured its scroller).
|
|
return (
|
|
<NavCategory>
|
|
<NavCategoryHeader>
|
|
<RoomNavCategoryButton
|
|
closed={closed}
|
|
data-category-id={categoryId}
|
|
onClick={onCategoryClick}
|
|
>
|
|
{section.name}
|
|
</RoomNavCategoryButton>
|
|
</NavCategoryHeader>
|
|
{items.map((roomId) => {
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) return null;
|
|
return (
|
|
<RoomNavItem
|
|
key={roomId}
|
|
room={room}
|
|
selected={selectedRoomId === roomId}
|
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
|
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
|
/>
|
|
);
|
|
})}
|
|
</NavCategory>
|
|
);
|
|
}
|
|
|
|
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>();
|
|
// Bumped whenever any room's `m.tag` account data changes (favourite/low-priority
|
|
// toggle via RoomNavItemMenu). Nothing else re-runs this memo on a tag change
|
|
// (see useRoomTagsVersion.ts), so without this dep a room only moves section
|
|
// once some unrelated event (e.g. an unread-count change) forces a re-render.
|
|
const roomTagsVersion = useRoomTagsVersion(mx);
|
|
|
|
const { favoriteRooms, lowPriorityRooms, otherRooms, customSections } = useMemo(() => {
|
|
const favs: string[] = [];
|
|
const low: string[] = [];
|
|
const others: string[] = [];
|
|
// [Gitea #108] u.* tags → custom sections. A room in a section leaves the
|
|
// plain "Rooms" list but keeps its Favorite / Low Priority placement.
|
|
const { sections, sectioned } = deriveRoomSections(
|
|
rooms.map((rId) => {
|
|
const room = mx.getRoom(rId);
|
|
return { roomId: rId, name: room?.name ?? rId, tags: room?.tags };
|
|
}),
|
|
);
|
|
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 if (!sectioned.has(rId)) {
|
|
others.push(rId);
|
|
}
|
|
});
|
|
return {
|
|
favoriteRooms: favs,
|
|
lowPriorityRooms: low,
|
|
otherRooms: others,
|
|
customSections: sections,
|
|
};
|
|
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
|
|
// this memo to re-run whenever any room's tags change.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mx, rooms, roomTagsVersion]);
|
|
|
|
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, mx);
|
|
} 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>
|
|
)}
|
|
{customSections.map((section) => (
|
|
<HomeCustomSection
|
|
key={section.tag}
|
|
section={section}
|
|
closed={closedCategories.has(makeNavCategoryId('home', section.tag))}
|
|
onCategoryClick={handleCategoryClick}
|
|
filterQuery={filterQuery}
|
|
selectedRoomId={selectedRoomId}
|
|
roomsWithUnreadSet={roomsWithUnreadSet}
|
|
notificationPreferences={notificationPreferences}
|
|
/>
|
|
))}
|
|
<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,
|
|
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>
|
|
);
|
|
}
|