Navigator: per-Mind Media row after Desk #96
5 changed files with 323 additions and 29 deletions
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
29
src/app/features/estate/MediaRow.test.tsx
Normal file
29
src/app/features/estate/MediaRow.test.tsx
Normal 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/"');
|
||||
});
|
||||
});
|
||||
83
src/app/features/estate/MediaRow.tsx
Normal file
83
src/app/features/estate/MediaRow.tsx
Normal 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);
|
||||
};
|
||||
93
src/app/features/estate/mediaRoom.test.ts
Normal file
93
src/app/features/estate/mediaRoom.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
90
src/app/features/estate/mediaRoom.ts
Normal file
90
src/app/features/estate/mediaRoom.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue