Room list: unjoined project-room groups collapse behind one Browse entry #67

Merged
void merged 2 commits from void/browse-collapse into dev 2026-11-10 14:47:43 +00:00
5 changed files with 114 additions and 13 deletions

View file

@ -38,7 +38,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie
import { makeNavCategoryId } from '../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { EstateRoom, EstateTier } from './buildEstate';
import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate';
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
import { useEstate, useEstateRootId } from './useEstate';
import * as css from './EstateNavigator.css';
@ -167,25 +167,53 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
);
}
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
return (
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
{group.rooms.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
</Collapsible>
);
}
/**
* Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is
* remembered per device like any other section.
*/
function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) {
const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom());
const key = makeNavCategoryId('estate', 'browse', tierId);
const open = toggled.has(key);
const count = groups.reduce((n, g) => n + g.rooms.length, 0);
return (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={!open}
onClick={() => setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })}
>
{`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`}
</RoomNavCategoryButton>
</NavCategoryHeader>
{open && groups.map((g) => <GroupSection key={g.mind} tierId={tierId} group={g} />)}
</NavCategory>
);
}
function Tier({ tier }: { tier: EstateTier }) {
const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
const { front, browse } = useMemo(() => splitGroups(tier), [tier]);
return (
<Collapsible categoryId={tier.id} label={tier.label} rooms={allRooms}>
{tier.loose.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
{tier.groups.map((g) => (
<Collapsible
key={g.mind}
categoryId={`${tier.id}|${g.mind}`}
label={g.label}
rooms={g.rooms}
>
{g.rooms.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}
</Collapsible>
{front.map((g) => (
<GroupSection key={g.mind} tierId={tier.id} group={g} />
))}
{browse.length > 0 && <Browse tierId={tier.id} groups={browse} />}
</Collapsible>
);
}

View file

@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate';
import {
buildEstate,
EstateHierarchyRoom,
EstateInput,
splitGroups,
stewardOfChild,
} from './buildEstate';
const ROOT = '!root:gamecult.org';
const PROJECTS = '!projects:gamecult.org';
@ -324,3 +330,34 @@ describe('direct invites', () => {
]);
});
});
describe('splitGroups', () => {
const projects = (membership: EstateInput['membership']) =>
buildEstate(input({ membership })).find((t) => t.id === PROJECTS)!;
const labels = (gs: { mind: string }[]) => gs.map((g) => g.mind);
it('puts a group nobody has joined behind Browse, and keeps loose estate rooms out of it', () => {
const tier = projects(new Map());
const { front, browse } = splitGroups(tier);
expect(labels(front)).toEqual([]);
expect(labels(browse).sort()).toEqual(['eureka', 'void']);
expect(tier.loose.map((r) => r.name)).toEqual(['Misc']);
});
it('keeps a group up front once any room in it is joined, its unjoined siblings with it', () => {
const { front, browse } = splitGroups(projects(new Map([['!cultlib:gamecult.org', 'join']])));
expect(labels(front)).toEqual(['eureka']);
expect(labels(browse)).toEqual(['void']);
});
it('keeps a group up front for an invite, which is something to act on', () => {
const { front } = splitGroups(projects(new Map([['!aeth:gamecult.org', 'invite']])));
expect(labels(front)).toEqual(['void']);
});
it('loses no group: front and browse together are the tier groups', () => {
const tier = projects(new Map([['!cultlib:gamecult.org', 'join']]));
const { front, browse } = splitGroups(tier);
expect([...front, ...browse].sort((a, b) => a.mind.localeCompare(b.mind))).toEqual(tier.groups);
});
});

View file

@ -71,6 +71,20 @@ export type EstateInput = {
mindLabels?: ReadonlyMap<string, string>;
};
/**
* A tier's project-room groups split for the navigator: a group the user is in anything of (joined, or invited, which is
* something to act on) stays up front; a group of rooms nobody has joined waits behind one Browse entry, so the first
* screen is conversations, not a wall of Join buttons. Loose (estate) rooms are never grouped away.
*/
export const splitGroups = (tier: EstateTier): { front: EstateGroup[]; browse: EstateGroup[] } => {
const front: EstateGroup[] = [];
const browse: EstateGroup[] = [];
tier.groups.forEach((g) =>
(g.rooms.some((r) => r.state !== 'joinable') ? front : browse).push(g)
);
return { front, browse };
};
export const DIRECT_TIER_ID = 'direct';
export const ELSEWHERE_TIER_ID = 'elsewhere';

View file

@ -0,0 +1,21 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { RoomNavCategoryButton } from './RoomNavCategoryButton';
const html = (closed?: boolean) =>
renderToStaticMarkup(<RoomNavCategoryButton closed={closed}>Browse</RoomNavCategoryButton>);
describe('RoomNavCategoryButton', () => {
it('says it is collapsed when closed, so assistive tech does not rely on the chevron', () => {
expect(html(true)).toContain('aria-expanded="false"');
});
it('says it is expanded when open', () => {
expect(html(false)).toContain('aria-expanded="true"');
});
it('claims neither when the section does not toggle', () => {
expect(html(undefined)).not.toContain('aria-expanded');
});
});

View file

@ -16,6 +16,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
src={closed ? Icons.ChevronRight : Icons.ChevronBottom}
/>
}
aria-expanded={closed === undefined ? undefined : !closed}
{...props}
ref={ref}
>