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

499 lines
18 KiB
TypeScript

import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
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, UnjoinedSubRoomItem } 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 { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { stopPropagation } from '../../../utils/keyboard';
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
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 { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
import { _RoomSearchParams } from '../../paths';
import { StateEvent } from '../../../../types/matrix/room';
type HomeMenuProps = {
requestClose: () => void;
onManageRooms: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(
({ requestClose, onManageRooms }, ref) => {
const orphanRooms = useHomeRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const handleMarkAsRead = () => {
if (!unread) return;
markRoomsAsRead(orphanRooms, hideActivity);
requestClose();
};
const handleManageRooms = () => {
requestClose();
onManageRooms();
};
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>
<MenuItem
onClick={handleManageRooms}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
aria-disabled={orphanRooms.length === 0}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Manage Rooms
</Text>
</MenuItem>
</Box>
</Menu>
);
}
);
function HomeHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [showManageRooms, setShowManageRooms] = useState(false);
const mx = useMatrixClient();
const orphanRooms = useHomeRooms();
const rooms = useMemo(() => {
return orphanRooms
.map((roomId) => mx.getRoom(roomId))
.filter((room): room is Room => room !== null);
}, [mx, orphanRooms]);
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 size="H4" truncate>
Home
</Text>
</Box>
<Box alignItems="Center" gap="100">
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<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)}
onManageRooms={() => setShowManageRooms(true)}
/>
</FocusTrap>
}
/>
{showManageRooms && (
<ManageRoomsPrompt
rooms={rooms}
onDone={() => setShowManageRooms(false)}
onCancel={() => setShowManageRooms(false)}
/>
)}
</>
);
}
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>
);
}
type RoomListItem =
| { type: 'room'; roomId: string; room: Room; depth: number; parentId?: string; isLast?: boolean }
| { type: 'unjoined-subroom'; roomId: string; depth: number; parentId: string; isLast?: boolean };
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useHomeRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
const createRoomSelected = useHomeCreateSelected();
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedRooms = useMemo(() => {
const items = Array.from(rooms).sort(
closedCategories.has(DEFAULT_CATEGORY_ID)
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx)
);
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
// Flatten rooms and their sub-rooms into a hierarchical list
const listItems = useMemo(() => {
const items: RoomListItem[] = [];
const processedRooms = new Set<string>();
const addRoomAndSubRooms = (roomId: string, depth: number = 0, parentId?: string, isLast: boolean = false) => {
if (processedRooms.has(roomId)) return; // Prevent infinite loops
processedRooms.add(roomId);
const room = mx.getRoom(roomId);
if (room) {
// Joined room - show normally
items.push({ type: 'room', roomId, room, depth, parentId, isLast });
// Get sub-rooms from room state
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
// Filter to only joined sub-rooms
const joinedSubRooms = subRooms.filter(subRoomId => mx.getRoom(subRoomId));
// Add sub-rooms recursively
joinedSubRooms.forEach((subRoomId, index) => {
addRoomAndSubRooms(subRoomId, depth + 1, roomId, index === joinedSubRooms.length - 1);
});
}
};
// Build set of all sub-room IDs to identify top-level rooms
const allSubRoomIds = new Set<string>();
sortedRooms.forEach(roomId => {
const room = mx.getRoom(roomId);
if (room) {
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
subRooms.forEach(id => allSubRoomIds.add(id));
}
});
// Only add top-level rooms (rooms that aren't sub-rooms of another)
sortedRooms.forEach(roomId => {
if (!allSubRoomIds.has(roomId)) {
addRoomAndSubRooms(roomId);
}
});
return items;
}, [mx, sortedRooms]);
const virtualizer = useVirtualizer({
count: listItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<HomeHeader />
{noRoomToDisplay ? (
<HomeEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="100">
<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 searchParams: _RoomSearchParams = {
viaServers: viaServers?.join(',')
};
navigate(
withSearchParam(
getHomeRoomPath(roomIdOrAlias),
searchParams
)
);
}}
/>
)}
</>
)}
</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>
<PluginNavSlot location="channel-list" />
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
</NavCategoryHeader>
<PluginNavSlot location="home-section" />
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = listItems[vItem.index];
if (!item) return null;
if (item.type === 'unjoined-subroom') {
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} isLast={item.isLast} />
</VirtualTile>
);
}
const selected = selectedRoomId === item.roomId;
const paddingLeft = item.depth > 0 ? '30px' : undefined;
let treeIcon = '';
if (item.depth > 0) {
treeIcon = item.isLast ? '╰' : '├';
}
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: item.depth > 0 ? '1.5rem' : undefined }}>
{treeIcon && (
<span style={{
paddingRight: '2px',
opacity: 0.5,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1',
userSelect: 'none'
}}>
{treeIcon}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem
room={item.room}
selected={selected}
hideIcon={item.depth > 0}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, item.roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
item.room.roomId
)}
/>
</div>
</div>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}