From c9d16f62597680bb598f00fe907f2b2ade559cdf Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:00:29 +0000 Subject: [PATCH] Navigator: a Media row after Desk in every Mind foldout, from the #-files alias or a carried room id Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 57 +++++++------ src/app/features/estate/MediaRow.test.tsx | 29 +++++++ src/app/features/estate/MediaRow.tsx | 83 ++++++++++++++++++ src/app/features/estate/mediaRoom.test.ts | 93 +++++++++++++++++++++ src/app/features/estate/mediaRoom.ts | 90 ++++++++++++++++++++ 5 files changed, 323 insertions(+), 29 deletions(-) create mode 100644 src/app/features/estate/MediaRow.test.tsx create mode 100644 src/app/features/estate/MediaRow.tsx create mode 100644 src/app/features/estate/mediaRoom.test.ts create mode 100644 src/app/features/estate/mediaRoom.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index cc98c337..2a38e1f0 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -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['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, 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 }) { - + @@ -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 ; + if (media.kind === 'unset') return ; + if (media.kind === 'error') return ; + if (media.room.state === 'joined') return ; + return ; } 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 ( + {hasMedia && } {rooms.map((r) => ( ))} diff --git a/src/app/features/estate/MediaRow.test.tsx b/src/app/features/estate/MediaRow.test.tsx new file mode 100644 index 00000000..6b590158 --- /dev/null +++ b/src/app/features/estate/MediaRow.test.tsx @@ -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( + + + + ); + 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( + + + + ); + expect(text(html)).toBe('Media'); + expect(html).toContain('href="/home/%23void-files%3Agamecult.org/"'); + }); +}); diff --git a/src/app/features/estate/MediaRow.tsx b/src/app/features/estate/MediaRow.tsx new file mode 100644 index 00000000..684fa6c5 --- /dev/null +++ b/src/app/features/estate/MediaRow.tsx @@ -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 = ( + + + +); + +/** 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 ( + + + + {mediaIcon} + + {`Media · ${note}`} + + + + + ); +} + +/** The joined Media row: opens the room, which shows its album view. */ +export function MediaLink({ to }: { to: string }) { + return ( + + + + + {mediaIcon} + + Media + + + + + + ); +} + +/** 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(); + invited.forEach((id) => membership.set(id, 'invite')); + joined.forEach((id) => membership.set(id, 'join')); + return mediaRow(lookup.data, membership); +}; diff --git a/src/app/features/estate/mediaRoom.test.ts b/src/app/features/estate/mediaRoom.test.ts new file mode 100644 index 00000000..2cfbce81 --- /dev/null +++ b/src/app/features/estate/mediaRoom.test.ts @@ -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' }); + }); +}); diff --git a/src/app/features/estate/mediaRoom.ts b/src/app/features/estate/mediaRoom.ts new file mode 100644 index 00000000..a99c3d9d --- /dev/null +++ b/src/app/features/estate/mediaRoom.ts @@ -0,0 +1,90 @@ +/** + * A Mind's media room: the file-zone room Eureka provisions under the alias #-files:. + * 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[]` (provisional shape, awaiting the follow-up above). + */ +export const mediaRoomIdFromStewardships = (content: unknown, mind: string): string | undefined => { + const id = (content as { media?: Record } | 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 => { + 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 +): 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, + }, + }; +}; -- 2.47.3