Navigator: Desk and media pages pinned at the top of each Mind foldout #94

Merged
void merged 1 commit from void/mind-desk-pin into dev 2026-11-11 16:48:19 +00:00
3 changed files with 60 additions and 25 deletions

View file

@ -1,16 +1,27 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Text } from 'folds';
import { Box, Icon, Icons, Text } from 'folds';
import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav';
import { NAV_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css';
import { getDeskPath } from '../../pages/pathUtils';
import { deskFor } from './source';
/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */
/** A Mind's desk, pinned at the top of its navigator group: always there, with the open count when there is one. */
export function DeskEntry({ mind }: { mind: string }) {
const open = deskFor(mind)?.open_count ?? 0;
if (open === 0) return null;
return (
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">
{`Desk · ${open} open`}
</Text>
<NavItem variant="Background" radii="400">
<NavLink to={getDeskPath(mind)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<NavGutter>
<Icon src={Icons.Bookmark} size="100" style={{ opacity: NAV_ICON_REST_OPACITY }} />
</NavGutter>
<Text className={NavLabel} as="span" size="Inherit" truncate>
{open > 0 ? `Desk · ${open} open` : 'Desk'}
</Text>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
);
}

View file

@ -139,12 +139,14 @@ describe('DeskEntry', () => {
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
expect(entry('void')).toContain('Desk · 5 open');
});
it('is absent at zero open, when nothing is published, and when only settled forks exist', () => {
setDeskSource(() => STATES.empty);
expect(entry('void')).toBe('');
setDeskSource(() => undefined);
expect(entry('void')).toBe('');
setDeskSource(() => ({ ...STATES.settled, open_count: 0 }));
expect(entry('void')).toBe('');
it('is pinned even at zero open, when nothing is published, and when only settled forks exist', () => {
const bare = (html: string) => html.replace(/<[^>]*>/g, '');
const states = [STATES.empty, undefined, { ...STATES.settled, open_count: 0 }];
states.forEach((state) => {
setDeskSource(() => state);
const html = entry('void');
expect(bare(html)).toBe('Desk');
expect(html).toContain('href="/desk/void"');
});
});
});

View file

@ -29,6 +29,8 @@ import { _RoomSearchParams } from '../../pages/paths';
import { searchModalAtom } from '../../state/searchModal';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { isFileZone } from '../albums/isFileZone';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
@ -140,11 +142,9 @@ type CollapsibleProps = {
categoryId: string;
label: string;
rooms: EstateRoom[];
/** Sits in the header beside the label, so a collapsed section still shows it. */
trailing?: ReactNode;
children: ReactNode;
};
function Collapsible({ categoryId, label, rooms, trailing, children }: CollapsibleProps) {
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const selectedRoomId = useSelectedRoom();
const roomToUnread = useAtomValue(roomToUnreadAtom);
@ -168,22 +168,44 @@ function Collapsible({ categoryId, label, rooms, trailing, children }: Collapsib
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
{label}
</RoomNavCategoryButton>
{trailing}
</NavCategoryHeader>
{!closed && <NavLevel>{children}</NavLevel>}
</NavCategory>
);
}
/** The Mind's media pages (file-zone rooms) first, then its other rooms in their given order. */
function useMediaFirst(rooms: EstateRoom[]): EstateRoom[] {
const mx = useMatrixClient();
const clientConfig = useClientConfig();
return useMemo(() => {
const isMedia = (r: EstateRoom) => {
const mxRoom = mx.getRoom(r.roomId);
return (
!!mxRoom &&
isFileZone(
{
roomId: r.roomId,
aliases: [mxRoom.getCanonicalAlias(), ...mxRoom.getAltAliases()].filter(
(a): a is string => !!a
),
createType: mxRoom.currentState.getStateEvents('m.room.create', '')?.getContent().type,
},
clientConfig
)
);
};
const media = rooms.filter(isMedia);
return [...media, ...rooms.filter((r) => !media.includes(r))];
}, [mx, clientConfig, rooms]);
}
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
const rooms = useMediaFirst(group.rooms);
return (
<Collapsible
categoryId={`${tierId}|${group.mind}`}
label={group.label}
rooms={group.rooms}
trailing={<DeskEntry mind={group.mind} />}
>
{group.rooms.map((r) => (
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
<DeskEntry mind={group.mind} />
{rooms.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
</Collapsible>