Navigator: per-Mind Media row after Desk #96

Merged
void merged 1 commit from void/mind-media-row into dev 2026-11-11 17:05:44 +00:00
5 changed files with 323 additions and 29 deletions

View file

@ -1,4 +1,11 @@
import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react';
import React, {
ComponentProps,
MouseEventHandler,
ReactNode,
useCallback,
useMemo,
useRef,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds';
@ -29,8 +36,9 @@ 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 { MINDS } from '../../components/message/pose/poses';
import { MediaLink, MediaNote, useMindMedia } from './MediaRow';
import { MediaRow as MediaRowState } from './mediaRoom';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
@ -49,6 +57,8 @@ import * as css from './EstateNavigator.css';
import { DeskEntry } from '../desk';
import { SkyStateControl } from '../scene/SkyStateControl';
type IconSrc = ComponentProps<typeof Icon>['src'];
/** How long a tap waits for the client store to hold the room it just joined. */
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
@ -62,7 +72,7 @@ const estateRoomPath = (mx: ReturnType<typeof useMatrixClient>, room: EstateRoom
return getHomeRoomPath(alias);
};
function JoinRow({ room }: { room: EstateRoom }) {
function JoinRow({ room, icon = Icons.Hash }: { room: EstateRoom; icon?: IconSrc }) {
const mx = useMatrixClient();
const navigate = useNavigate();
const [state, join] = useAsyncCallback(
@ -86,7 +96,7 @@ function JoinRow({ room }: { room: EstateRoom }) {
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<NavGutter>
<Avatar size="200" radii="400">
<Icon src={Icons.Hash} size="100" style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }} />
<Icon src={icon} size="100" style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }} />
</Avatar>
</NavGutter>
<Text className={NavLabel} as="span" size="Inherit" truncate>
@ -174,37 +184,26 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
);
}
/** The Mind's media pages (file-zone rooms) first, then its other rooms in their given order. */
function useMediaFirst(rooms: EstateRoom[]): EstateRoom[] {
/** The Media row every Mind in the roster has: its file-zone room, or an honest note that there is none yet. */
function MindMedia({ media }: { media: MediaRowState }) {
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]);
if (media.kind === 'checking') return <MediaNote note="looking" />;
if (media.kind === 'unset') return <MediaNote note="not set up" />;
if (media.kind === 'error') return <MediaNote note="unavailable" title={media.message} />;
if (media.room.state === 'joined') return <MediaLink to={estateRoomPath(mx, media.room)} />;
return <JoinRow room={media.room} icon={Icons.Photo} />;
}
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
const rooms = useMediaFirst(group.rooms);
const media = useMindMedia(group.mind);
const hasMedia = MINDS.includes(group.mind);
// The media room is the Media row; it is not listed a second time among the Mind's rooms.
const rooms =
media.kind === 'room' ? group.rooms.filter((r) => r.roomId !== media.room.roomId) : group.rooms;
return (
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
<DeskEntry mind={group.mind} />
{hasMedia && <MindMedia media={media} />}
{rooms.map((r) => (
<EstateRow key={r.roomId} room={r} />
))}

View file

@ -0,0 +1,29 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { MediaLink, MediaNote } from './MediaRow';
const text = (html: string) => html.replace(/<[^>]*>/g, '');
describe('Media row', () => {
it('a Mind without a media room reads "not set up" and links nowhere', () => {
const html = renderToStaticMarkup(
<MemoryRouter>
<MediaNote note="not set up" />
</MemoryRouter>
);
expect(text(html)).toBe('Media · not set up');
expect(html).not.toContain('href=');
});
it('a joined media room reads "Media" and opens the room', () => {
const html = renderToStaticMarkup(
<MemoryRouter>
<MediaLink to="/home/%23void-files%3Agamecult.org/" />
</MemoryRouter>
);
expect(text(html)).toBe('Media');
expect(html).toContain('href="/home/%23void-files%3Agamecult.org/"');
});
});

View file

@ -0,0 +1,83 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import { Avatar, Box, Icon, Icons, Text } from 'folds';
import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav';
import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css';
import { MINDS } from '../../components/message/pose/poses';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useEstateRootId } from './useEstate';
import {
STEWARDSHIPS_KEY,
MediaRow as MediaRowState,
lookupMediaRoom,
mediaRoomIdFromStewardships,
mediaRow,
} from './mediaRoom';
const mediaIcon = (
<Avatar size="200" radii="400">
<Icon src={Icons.Photo} size="100" style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }} />
</Avatar>
);
/** A Media row that is not a link: not set up, still looking, or the lookup failed. Says so; shows no data. */
export function MediaNote({ note, title }: { note: string; title?: string }) {
return (
<NavItem variant="Background" radii="400">
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200" title={title}>
<NavGutter>{mediaIcon}</NavGutter>
<Text className={NavLabel} as="span" size="Inherit" truncate priority="300">
{`Media · ${note}`}
</Text>
</Box>
</NavItemContent>
</NavItem>
);
}
/** The joined Media row: opens the room, which shows its album view. */
export function MediaLink({ to }: { to: string }) {
return (
<NavItem variant="Background" radii="400">
<NavLink to={to}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<NavGutter>{mediaIcon}</NavGutter>
<Text className={NavLabel} as="span" size="Inherit" truncate>
Media
</Text>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
);
}
/** The Mind's media room as the navigator sees it right now. */
export const useMindMedia = (mind: string): MediaRowState => {
const mx = useMatrixClient();
const { rootId } = useEstateRootId();
const joined = useAtomValue(allRoomsAtom);
const invited = useAtomValue(allInvitesAtom);
const server = mx.getDomain() ?? '';
const carried = mediaRoomIdFromStewardships(
rootId
? mx.getRoom(rootId)?.currentState.getStateEvents(STEWARDSHIPS_KEY, '')?.getContent()
: undefined,
mind
);
const lookup = useQuery({
queryKey: ['mind-media', mind, server, carried],
queryFn: () => lookupMediaRoom(mx, mind, server, carried),
staleTime: 60_000,
enabled: MINDS.includes(mind),
});
const membership = new Map<string, 'join' | 'invite'>();
invited.forEach((id) => membership.set(id, 'invite'));
joined.forEach((id) => membership.set(id, 'join'));
return mediaRow(lookup.data, membership);
};

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { lookupMediaRoom, mediaAlias, mediaRoomIdFromStewardships, mediaRow } from './mediaRoom';
const notFound = Object.assign(new Error('Room alias not found'), {
httpStatus: 404,
errcode: 'M_NOT_FOUND',
});
const clientFor = (answer: () => Promise<{ room_id: string; servers?: string[] }>) => {
const asked: string[] = [];
return {
asked,
mx: {
getRoomIdForAlias: (alias: string) => {
asked.push(alias);
return answer();
},
},
};
};
describe('the Media row of a Mind', () => {
it("looks the room up by Eureka's alias pattern", async () => {
const { mx, asked } = clientFor(async () => ({
room_id: '!v:gamecult.org',
servers: ['gamecult.org'],
}));
expect(mediaAlias('void', 'gamecult.org')).toBe('#void-files:gamecult.org');
expect(await lookupMediaRoom(mx, 'void', 'gamecult.org')).toEqual({
found: true,
roomId: '!v:gamecult.org',
via: ['gamecult.org'],
});
expect(asked).toEqual(['#void-files:gamecult.org']);
});
it('uses a room id the stewardships state carries and asks nobody', async () => {
const { mx, asked } = clientFor(async () => {
throw new Error('must not be asked');
});
const carried = mediaRoomIdFromStewardships(
{ void: ['GameCult/x'], media: { void: '!c:gamecult.org' } },
'void'
);
expect(carried).toBe('!c:gamecult.org');
expect(await lookupMediaRoom(mx, 'void', 'gamecult.org', carried)).toMatchObject({
found: true,
roomId: '!c:gamecult.org',
});
expect(asked).toEqual([]);
});
it('ignores a stewardships state that carries no id for the Mind, or something that is not a room id', () => {
expect(mediaRoomIdFromStewardships({ void: ['GameCult/x'] }, 'void')).toBeUndefined();
expect(mediaRoomIdFromStewardships({ media: { void: 'nope' } }, 'void')).toBeUndefined();
expect(mediaRoomIdFromStewardships(undefined, 'void')).toBeUndefined();
});
it('says not set up when the alias does not exist, and unavailable when the lookup fails otherwise', async () => {
const missing = clientFor(async () => {
throw notFound;
});
expect(await lookupMediaRoom(missing.mx, 'kiko', 'gamecult.org')).toEqual({
found: false,
reason: 'unset',
});
const down = clientFor(async () => {
throw Object.assign(new Error('gateway'), { httpStatus: 502 });
});
expect(await lookupMediaRoom(down.mx, 'kiko', 'gamecult.org')).toMatchObject({
found: false,
reason: 'error',
message: 'gateway',
});
});
it("joins the found room to the user's membership: joined opens, invited and unjoined are joinable", () => {
const found = { found: true as const, roomId: '!m:gamecult.org', via: ['gamecult.org'] };
const state = (m?: 'join' | 'invite') =>
mediaRow(found, new Map(m ? [['!m:gamecult.org', m]] : []));
expect(state('join')).toMatchObject({ kind: 'room', room: { state: 'joined', name: 'Media' } });
expect(state('invite')).toMatchObject({ kind: 'room', room: { state: 'invited' } });
expect(state()).toMatchObject({
kind: 'room',
room: { state: 'joinable', via: ['gamecult.org'], direct: false },
});
});
it('shows no room while the lookup is out, and none for a room that is not there', () => {
expect(mediaRow(undefined, new Map())).toEqual({ kind: 'checking' });
expect(mediaRow({ found: false, reason: 'unset' }, new Map())).toEqual({ kind: 'unset' });
});
});

View file

@ -0,0 +1,90 @@
/**
* A Mind's media room: the file-zone room Eureka provisions under the alias #<mind>-files:<server>.
* The navigator shows a Media row for every Mind; this module decides what that row says and where it points.
* A room id carried by the root Space's org.gamecult.stewardships state wins over the alias; the alias is the
* convention until the provisioning projection carries ids (ratatoskr:follow_up:stewardships-carry-room-ids).
*/
import { EstateRoom } from './buildEstate';
export const STEWARDSHIPS_KEY = 'org.gamecult.stewardships';
export const mediaAlias = (mind: string, server: string): string => `#${mind}-files:${server}`;
/**
* The room id the stewardships state names for a Mind's media room, if it does. The state's mind-to-repos
* lists are not read here; the id lives under `media[<mind>]` (provisional shape, awaiting the follow-up above).
*/
export const mediaRoomIdFromStewardships = (content: unknown, mind: string): string | undefined => {
const id = (content as { media?: Record<string, unknown> } | null | undefined)?.media?.[mind];
return typeof id === 'string' && id.startsWith('!') ? id : undefined;
};
type MediaClient = {
getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers?: string[] }>;
};
export type MediaLookup =
| { found: true; roomId: string; via: string[] }
| { found: false; reason: 'unset' | 'error'; message?: string };
/** Resolve a Mind's media room: a carried id if there is one, else the alias. A 404 means it is not set up. */
export const lookupMediaRoom = async (
mx: MediaClient,
mind: string,
server: string,
carriedId?: string
): Promise<MediaLookup> => {
if (carriedId) return { found: true, roomId: carriedId, via: [server] };
try {
const r = await mx.getRoomIdForAlias(mediaAlias(mind, server));
return { found: true, roomId: r.room_id, via: r.servers?.length ? r.servers : [server] };
} catch (e) {
const err = e as {
httpStatus?: number;
errcode?: string;
data?: { errcode?: string };
message?: string;
};
const missing = err.httpStatus === 404 || (err.errcode ?? err.data?.errcode) === 'M_NOT_FOUND';
return missing
? { found: false, reason: 'unset' }
: {
found: false,
reason: 'error',
message: err.message ?? 'Could not look up the media room',
};
}
};
export type MediaRow =
| { kind: 'checking' }
| { kind: 'unset' }
| { kind: 'error'; message: string }
| { kind: 'room'; room: EstateRoom };
const MEMBERSHIP_STATE = { join: 'joined', invite: 'invited', none: 'joinable' } as const;
/** What the Media row shows: the lookup result joined to the user's own membership. */
export const mediaRow = (
lookup: MediaLookup | undefined,
membership: ReadonlyMap<string, 'join' | 'invite'>
): MediaRow => {
if (!lookup) return { kind: 'checking' };
if (!lookup.found) {
return lookup.reason === 'unset'
? { kind: 'unset' }
: { kind: 'error', message: lookup.message ?? 'Could not look up the media room' };
}
const m = membership.get(lookup.roomId);
return {
kind: 'room',
room: {
roomId: lookup.roomId,
name: 'Media',
state: MEMBERSHIP_STATE[m ?? 'none'],
via: lookup.via,
parentId: null,
direct: false,
},
};
};