Files
cinny/src/app/pages/client/home/Home.tsx
T

510 lines
17 KiB
TypeScript
Raw Normal View History

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 } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
2025-08-05 18:37:07 +05:30
import {
2025-08-16 17:10:39 +05:30
encodeSearchParamValueArray,
2025-08-05 18:37:07 +05:30
getExplorePath,
getHomeCreatePath,
getHomeRoomPath,
getHomeSearchPath,
2025-08-16 17:10:39 +05:30
withSearchParam,
2025-08-05 18:37:07 +05:30
} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
2025-08-05 18:37:07 +05:30
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';
2025-08-29 15:04:52 +05:30
import { markAsRead } from '../../../utils/notifications';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
2024-07-18 18:50:20 +05:30
import { stopPropagation } from '../../../utils/keyboard';
2025-02-26 21:44:53 +11:00
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
2025-08-16 17:10:39 +05:30
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();
2025-02-26 21:44:53 +11:00
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
2024-07-22 16:17:19 +05:30
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
2025-02-26 21:44:53 +11:00
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',
2024-07-18 18:50:20 +05:30
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={
<>
2025-08-05 18:37:07 +05:30
<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');
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;
},
),
[],
),
);
2025-08-05 18:37:07 +05:30
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
2025-08-05 18:37:07 +05:30
const createRoomSelected = useHomeCreateSelected();
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const { favoriteRooms, otherRooms } = useMemo(() => {
const favs: string[] = [];
const others: string[] = [];
rooms.forEach((rId) => {
const room = mx.getRoom(rId);
if (room?.tags?.['m.favourite']) {
favs.push(rId);
} else {
others.push(rId);
}
});
return { favoriteRooms: favs, otherRooms: others };
}, [mx, rooms]);
const sortedFavoriteRooms = useMemo(
() =>
Array.from(favoriteRooms).sort(
closedCategories.has(FAVORITES_CATEGORY_ID)
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx),
),
[mx, favoriteRooms, closedCategories],
);
const sortedRooms = useMemo(() => {
const items = Array.from(otherRooms).sort(
closedCategories.has(DEFAULT_CATEGORY_ID)
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx),
);
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, otherRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
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: sortedFavoriteRooms.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const virtualizer = useVirtualizer({
count: filteredRooms.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>
2025-08-05 18:37:07 +05:30
<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>
2025-08-16 17:10:39 +05:30
<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,
2025-08-16 17:10:39 +05:30
);
}}
/>
)}
</>
)}
</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
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>
{sortedFavoriteRooms.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 = sortedFavoriteRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
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>
</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={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}