499 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|