diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx
index 2cbbfe4a..56b554fa 100644
--- a/src/app/features/estate/EstateNavigator.tsx
+++ b/src/app/features/estate/EstateNavigator.tsx
@@ -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 (
+
+ {group.rooms.map((r) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })}
+ >
+ {`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`}
+
+
+ {open && groups.map((g) => )}
+
+ );
+}
+
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 (
{tier.loose.map((r) => (
))}
- {tier.groups.map((g) => (
-
- {g.rooms.map((r) => (
-
- ))}
-
+ {front.map((g) => (
+
))}
+ {browse.length > 0 && }
);
}
diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts
index 8f898a0f..7b63eac5 100644
--- a/src/app/features/estate/buildEstate.test.ts
+++ b/src/app/features/estate/buildEstate.test.ts
@@ -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);
+ });
+});
diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts
index b9232beb..ca656211 100644
--- a/src/app/features/estate/buildEstate.ts
+++ b/src/app/features/estate/buildEstate.ts
@@ -71,6 +71,20 @@ export type EstateInput = {
mindLabels?: ReadonlyMap;
};
+/**
+ * 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';
diff --git a/src/app/features/room-nav/RoomNavCategoryButton.test.tsx b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx
new file mode 100644
index 00000000..a500f920
--- /dev/null
+++ b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx
@@ -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(Browse);
+
+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');
+ });
+});
diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx
index 3af8aeac..e232b856 100644
--- a/src/app/features/room-nav/RoomNavCategoryButton.tsx
+++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx
@@ -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}
>