Albums slice 1: read-only album view for the file zone #70

Merged
void merged 5 commits from void/albums-slice-1-r2 into dev 2026-11-11 00:25:37 +00:00
18 changed files with 1022 additions and 8 deletions

View file

@ -0,0 +1,51 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`;
/** One column, bare ink on the scene. */
export const Column = style({
maxWidth: toRem(720),
width: '100%',
margin: '0 auto',
padding: config.space.S400,
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
});
/** A tappable text row: no border, no fill, at least 44 px tall. */
export const Row = style({
all: 'unset',
boxSizing: 'border-box',
cursor: 'pointer',
minHeight: 44,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: toRem(2),
color: color.Background.OnContainer,
':focus-visible': { textDecoration: 'underline' },
});
export const Back = style([Row, { minHeight: 44 }]);
export const Files = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S400,
});
/** Images may tile into a grid on wide screens only. */
export const FilesWide = style({
'@media': {
[WIDE]: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
alignItems: 'start',
},
},
});
export const Dim = style({ opacity: config.opacity.P300 });

View file

@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Scroll, Text } from 'folds';
import { Room, RoomEvent } from 'matrix-js-sdk';
import { MediaControl } from '../../components/media';
import { AudioContent } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { IAudioContent } from '../../../types/matrix/common';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { markAsRead } from '../../utils/notifications';
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile } from './buildAlbums';
import { useAlbums } from './useAlbums';
import * as css from './AlbumView.css';
/**
* Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the
* title above it is the only label and the transport controls sit on the scene. Anything that is
* not playable audio falls through to the stock renderer (a file download).
*/
const playableAudio = (content: Record<string, unknown>) => {
const c = content as IAudioContent;
const url = c.file?.url ?? c.url;
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? '');
if (!c.info || typeof url !== 'string' || !mimeType.startsWith('audio')) return undefined;
return { info: c.info, url, mimeType, encInfo: c.file };
};
type FileRowProps = { room: Room; file: AlbumFile; author: string };
function FileRow({ room, file, author }: FileRowProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const htmlReactParserOptions = useMemo(
() =>
getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: LINKIFY_OPTS, useAuthentication }),
[mx, room.roomId, useAuthentication]
);
const audio = file.kind === 'audio' ? playableAudio(file.content) : undefined;
return (
<Box direction="Column" gap="100">
<Text size="T300" truncate>
{file.name}
</Text>
{audio ? (
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
) : (
<RenderMessageContent
displayName={author}
msgType={file.content.msgtype as string}
ts={file.ts}
getContent={<T,>() => file.content as T}
mediaAutoLoad={mediaAutoLoad}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={LINKIFY_OPTS}
/>
)}
</Box>
);
}
const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;
/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */
function useMarkZoneRead(room: Room) {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
useEffect(() => {
const mark = () => {
markAsRead(mx, room.roomId, hideActivity).catch(() => undefined);
};
mark();
room.on(RoomEvent.Timeline, mark);
return () => {
room.off(RoomEvent.Timeline, mark);
};
}, [mx, room, hideActivity]);
}
type AlbumViewProps = { room: Room; onTimeline: () => void };
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
useMarkZoneRead(room);
const { loading, error, albums } = useAlbums(room);
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
const [openId, setOpenId] = useState<string>();
const open: Album | undefined = albums.find((a) => a.id === openId);
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
const encrypted = room.hasEncryptionStateEvent();
let body: React.ReactNode;
if (loading) body = <Text>Loading albums…</Text>;
else if (error) body = <Text>Could not load albums: {error.message}</Text>;
else if (open) {
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
body = (
<>
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
<Text size="B400">‹ Albums</Text>
</button>
<Text size="H4">{open.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
</Text>
{open.count === 0 && <Text>This album has no files.</Text>}
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
{open.files.map((f) => (
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
))}
</div>
</>
);
} else if (encrypted)
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
else {
body = albums.map((a) => (
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
<Text size="T400">{a.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
</Text>
</button>
));
}
return (
<Scroll hideTrack visibility="Hover">
<div className={css.Column}>
{body}
<button type="button" className={css.Row} onClick={onTimeline}>
<Text size="B400">Timeline ›</Text>
</button>
</div>
</Scroll>
);
}

View file

@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@void:x',
origin_server_ts: ts,
content: { msgtype: 'm.text', body: `album ${id}` },
...over,
});
const reply = (id: string, ts: number, msgtype: string, body = id, extra = {}): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@void:x',
origin_server_ts: ts,
content: { msgtype, body, ...extra },
});
const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }])[0];
describe('buildAlbums', () => {
it('lists the newest album first', () => {
const inputs: AlbumInput[] = [
{ root: root('$old', 100), replies: [] },
{ root: root('$new', 300), replies: [] },
{ root: root('$mid', 200), replies: [] },
];
expect(buildAlbums(inputs).map((a) => a.id)).toEqual(['$new', '$mid', '$old']);
});
it('takes title, author and date from the root', () => {
const a = one(root('$r', 555, { sender: '@eureka:x' }), []);
expect([a.title, a.author, a.ts]).toEqual(['album $r', '@eureka:x', 555]);
});
it('lists files oldest first whatever order the server returned them in', () => {
const a = one(root('$r', 1), [
reply('$c', 30, 'm.audio'),
reply('$a', 10, 'm.audio'),
reply('$b', 20, 'm.audio'),
]);
expect(a.files.map((f) => f.id)).toEqual(['$a', '$b', '$c']);
});
it('counts media replies only; a text reply is not a file', () => {
const a = one(root('$r', 1), [
reply('$t', 5, 'm.text', 'nice'),
reply('$n', 6, 'm.notice', 'bot'),
reply('$a', 10, 'm.audio'),
reply('$f', 11, 'm.file'),
]);
expect(a.count).toBe(2);
expect(a.files.map((f) => f.id)).toEqual(['$a', '$f']);
});
it('keeps a root with no media as an album of zero files', () => {
const a = one(root('$r', 1), [reply('$t', 5, 'm.text')]);
expect(a).toMatchObject({ id: '$r', count: 0, files: [], cover: undefined });
});
it('uses the first image as the cover', () => {
const a = one(root('$r', 1), [
reply('$a', 5, 'm.audio'),
reply('$i2', 20, 'm.image'),
reply('$i1', 10, 'm.image'),
]);
expect(a.cover?.id).toBe('$i1');
expect(one(root('$q', 1), [reply('$a', 5, 'm.audio')]).cover).toBeUndefined();
});
it('derives the kind from the msgtype', () => {
const a = one(root('$r', 1), [
reply('$1', 1, 'm.audio'),
reply('$2', 2, 'm.image'),
reply('$3', 3, 'm.video'),
reply('$4', 4, 'm.file'),
]);
expect(a.files.map((f) => f.kind)).toEqual(['audio', 'image', 'video', 'file']);
});
it('names a file by filename, else body', () => {
const a = one(root('$r', 1), [
reply('$1', 1, 'm.audio', 'caption', { filename: 'clip.wav' }),
reply('$2', 2, 'm.audio', 'plain.wav'),
]);
expect(a.files.map((f) => f.name)).toEqual(['clip.wav', 'plain.wav']);
});
it('does not duplicate a file the relations endpoint returned twice', () => {
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), reply('$a', 5, 'm.audio')]);
expect(a.count).toBe(1);
});
it('does not duplicate an album listed twice', () => {
expect(
buildAlbums([
{ root: root('$r', 1), replies: [] },
{ root: root('$r', 1), replies: [] },
])
).toHaveLength(1);
});
it('skips redacted and empty events', () => {
const redacted = {
...reply('$x', 5, 'm.audio'),
content: {},
unsigned: { redacted_because: {} },
};
const stillTyped = { ...reply('$y', 6, 'm.audio'), unsigned: { redacted_because: {} } };
const empty: ZoneEvent = { event_id: '$z' };
const a = one(root('$r', 1), [redacted, stillTyped, empty, reply('$ok', 7, 'm.audio')]);
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
expect(buildAlbums([{ root: root('$g', 1, { content: {} }), replies: [] }])).toEqual([]);
expect(
buildAlbums([{ root: root('$h', 1, { unsigned: { redacted_because: {} } }), replies: [] }])
).toEqual([]);
});
it('tolerates events with missing fields', () => {
const a = one({ event_id: '$r', content: { msgtype: 'm.text' } }, [
{ event_id: '$a', content: { msgtype: 'm.audio' } },
]);
expect(a).toMatchObject({ title: 'Untitled album', author: '', ts: 0, count: 1 });
});
it('does not read inherited object keys as file kinds', () => {
const a = one(root('$r', 1), [
reply('$c', 1, 'constructor'),
reply('$p', 2, '__proto__'),
reply('$t', 3, 'toString'),
]);
expect(a.count).toBe(0);
});
it('skips events that are not room messages even if their content looks like media', () => {
const reaction = { ...reply('$x', 5, 'm.audio'), type: 'm.reaction' };
const a = one(root('$r', 1), [reaction, reply('$ok', 6, 'm.audio')]);
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
expect(buildAlbums([{ root: root('$g', 1, { type: 'm.reaction' }), replies: [] }])).toEqual([]);
});
it('skips events without an event id', () => {
const anon: ZoneEvent = { type: 'm.room.message', content: { msgtype: 'm.audio', body: 'a' } };
expect(one(root('$r', 1), [anon]).count).toBe(0);
expect(
buildAlbums([{ root: { content: { msgtype: 'm.text', body: 'x' } }, replies: [] }])
).toEqual([]);
});
it('titles an edited album by its latest edit', () => {
const edited = root('$r', 1, {
unsigned: {
'm.relations': {
'm.replace': { content: { 'm.new_content': { msgtype: 'm.text', body: 'renamed' } } },
},
},
});
expect(one(edited, []).title).toBe('renamed');
const emptyEdit = root('$q', 1, { unsigned: { 'm.relations': { 'm.replace': {} } } });
expect(one(emptyEdit, []).title).toBe('album $q');
});
it('survives null or non-object entries in inputs and replies', () => {
const bad = [null, undefined, 'x', 7] as unknown as ZoneEvent[];
const a = buildAlbums([
null as unknown as AlbumInput,
{ root: null as unknown as ZoneEvent, replies: [] },
{ root: root('$r', 1), replies: [...bad, reply('$ok', 2, 'm.audio')] },
{ root: root('$s', 2), replies: null as unknown as ZoneEvent[] },
]);
expect(a.map((x) => x.id)).toEqual(['$s', '$r']);
expect(a[1].files.map((f) => f.id)).toEqual(['$ok']);
});
it('treats a non-numeric timestamp as 0 so it sorts first, not by string order', () => {
const a = one(root('$r', 1), [
reply('$b', 20, 'm.audio'),
{ ...reply('$bad', 0, 'm.audio'), origin_server_ts: '5' as unknown as number },
{ ...reply('$nan', 0, 'm.audio'), origin_server_ts: NaN },
reply('$a', 10, 'm.audio'),
]);
expect(a.files.map((f) => f.id)).toEqual(['$bad', '$nan', '$a', '$b']);
const albums = buildAlbums([
{ root: root('$x', 100), replies: [] },
{ root: root('$y', 0, { origin_server_ts: '999' as unknown as number }), replies: [] },
]);
expect(albums.map((x) => [x.id, x.ts])).toEqual([
['$x', 100],
['$y', 0],
]);
});
it('gives an album with an empty body a fallback title', () => {
expect(one(root('$r', 1, { content: { msgtype: 'm.text', body: '' } }), []).title).toBe(
'Untitled album'
);
expect(one(root('$q', 1, { content: { msgtype: 'm.text' } }), []).title).toBe('Untitled album');
});
it('never counts an edit event as a file or an album', () => {
const edit = reply('$e', 9, 'm.audio', '* x', {
'm.relates_to': { rel_type: 'm.replace', event_id: '$a' },
'm.new_content': { msgtype: 'm.audio', body: 'x' },
});
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), edit]);
expect(a.files.map((f) => f.id)).toEqual(['$a']);
const rootEdit = root('$re', 1, {
content: { msgtype: 'm.text', body: '* t', 'm.relates_to': { rel_type: 'm.replace' } },
});
expect(buildAlbums([{ root: rootEdit, replies: [] }])).toEqual([]);
const threaded = reply('$t', 6, 'm.audio', 't', {
'm.relates_to': { rel_type: 'm.thread', event_id: '$r' },
});
expect(one(root('$q', 1), [threaded]).count).toBe(1);
});
});

View file

@ -0,0 +1,121 @@
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
// into albums. See docs/file-zone.md for the rulings this encodes.
/** The slice of a Matrix event this module reads. Every field may be missing. */
export type ZoneEvent = {
event_id?: string;
type?: string;
sender?: string;
origin_server_ts?: number;
content?: Record<string, unknown>;
unsigned?: Record<string, unknown>;
};
export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] };
export type FileKind = 'audio' | 'image' | 'video' | 'file';
export type AlbumFile = {
id: string;
kind: FileKind;
name: string;
ts: number;
/** The event content, as RenderMessageContent reads it. */
content: Record<string, unknown>;
};
export type Album = {
id: string;
title: string;
author: string;
ts: number;
cover?: AlbumFile;
files: AlbumFile[];
count: number;
};
// A Map, not an object: msgtype is server-supplied and "constructor" must not name a kind.
const KIND_OF_MSGTYPE = new Map<string, FileKind>([
['m.audio', 'audio'],
['m.image', 'image'],
['m.video', 'video'],
['m.file', 'file'],
]);
// A redacted event keeps its id but loses its content and gains unsigned.redacted_because.
const live = (
e: ZoneEvent
): e is ZoneEvent & { event_id: string; content: Record<string, unknown> } =>
!!e &&
typeof e === 'object' &&
!!e.event_id &&
// An edit is an m.room.message too, but it revises an event; it is never a file or an album.
(e.content?.['m.relates_to'] as { rel_type?: unknown } | undefined)?.rel_type !== 'm.replace' &&
!e.unsigned?.redacted_because &&
(e.type === undefined || e.type === 'm.room.message') &&
typeof e.content?.msgtype === 'string';
const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
if (!live(e)) return undefined;
const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string);
if (!kind) return undefined;
return {
id: e.event_id,
kind,
name: asString(e.content.filename) || asString(e.content.body),
ts: tsOf(e.origin_server_ts),
content: e.content,
};
};
export const UNTITLED = 'Untitled album';
// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is
// that edit's m.new_content.body, else the original body.
const titleOf = (root: ZoneEvent & { content: Record<string, unknown> }): string => {
const relations = root.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
const edit = relations?.['m.replace'] as ZoneEvent | undefined;
const edited = (edit?.content?.['m.new_content'] as Record<string, unknown> | undefined)?.body;
return asString(edited) || asString(root.content.body) || UNTITLED;
};
const toAlbum = (input: AlbumInput): Album | undefined => {
const { root, replies } = input ?? {};
if (!live(root)) return undefined;
const seen = new Set<string>();
const files = (Array.isArray(replies) ? replies : [])
.map(toFile)
.filter((f): f is AlbumFile => {
if (!f || seen.has(f.id)) return false;
seen.add(f.id);
return true;
})
.sort((a, b) => a.ts - b.ts);
return {
id: root.event_id,
title: titleOf(root),
author: root.sender ?? '',
ts: tsOf(root.origin_server_ts),
cover: files.find((f) => f.kind === 'image'),
files,
count: files.length,
};
};
/** Newest album first; files oldest first. Loose room messages never reach this function. */
export const buildAlbums = (inputs: AlbumInput[]): Album[] => {
const seen = new Set<string>();
return inputs
.map(toAlbum)
.filter((a): a is Album => {
if (!a || seen.has(a.id)) return false;
seen.add(a.id);
return true;
})
.sort((a, b) => b.ts - a.ts);
};

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { isFileZone } from './isFileZone';
const room = { roomId: '!f:x', aliases: ['#files:x'] };
describe('isFileZone', () => {
it('is true for the file-zone create type', () => {
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true);
});
it('is false for other create types and for plain rooms', () => {
expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false);
expect(isFileZone(room, {})).toBe(false);
expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false);
});
it('is true when the room id or an alias is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true);
expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true);
});
it('is false when only another room is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false);
});
});

View file

@ -0,0 +1,14 @@
export const FILE_ZONE_TYPE = 'org.gamecult.file_zone';
export type FileZoneRoom = {
roomId: string;
/** Canonical and alternative aliases. */
aliases?: string[];
/** The `type` of the room's m.room.create content. */
createType?: string;
};
/** A room is the file zone when its create type says so or the client config lists it. */
export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean =>
room.createType === FILE_ZONE_TYPE ||
!!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r));

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { mapLimit } from './mapLimit';
describe('mapLimit', () => {
it('keeps result order and never exceeds the limit', async () => {
let inFlight = 0;
let peak = 0;
const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => {
setTimeout(r, n);
});
inFlight -= 1;
return n * 10;
});
expect(out).toEqual([50, 10, 40, 20, 30, 0]);
expect(peak).toBe(2);
});
it('handles an empty list and rejects when a call rejects', async () => {
expect(await mapLimit([], 3, async (n) => n)).toEqual([]);
await expect(
mapLimit([1, 2], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n;
})
).rejects.toThrow('boom');
});
});

View file

@ -0,0 +1,19 @@
/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */
export const mapLimit = async <T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> => {
const out = new Array<R>(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next;
next += 1;
// eslint-disable-next-line no-await-in-loop
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
};

View file

@ -0,0 +1,73 @@
import { useQuery } from '@tanstack/react-query';
import { ClientPrefix, Method, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { Album, ZoneEvent, buildAlbums } from './buildAlbums';
import { isFileZone } from './isFileZone';
import { mapLimit } from './mapLimit';
const REPLY_FETCHES_IN_FLIGHT = 6;
const REFRESH_MS = 30_000;
type Chunk = { chunk?: ZoneEvent[] };
/** True when the room is the file zone: by its create type, or listed in the client config. */
export const useIsFileZone = (room: Room): boolean => {
const config = useClientConfig();
// Subscribes to alias changes (alt aliases live in the same event).
useStateEvent(room, StateEvent.RoomCanonicalAlias);
const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type;
return isFileZone(
{
roomId: room.roomId,
aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a),
createType,
},
config
);
};
export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] };
/**
* Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with
* more albums or files than that is truncated; pagination is a later slice. Reads are made with
* the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and
* are skipped.
*/
export const useAlbums = (room: Room): AlbumsState => {
const mx = useMatrixClient();
const { roomId } = room;
const query = useQuery({
queryKey: ['albums', roomId],
queryFn: async (): Promise<Album[]> => {
const get = (path: string, params: Record<string, string>) =>
mx.http.authedRequest<Chunk>(Method.Get, path, params, undefined, {
prefix: ClientPrefix.V1,
});
const room_ = encodeURIComponent(roomId);
const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? [];
const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({
root,
replies:
(
await get(
`/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`,
{ limit: '100', dir: 'f' }
)
).chunk ?? [],
}));
return buildAlbums(inputs);
},
retry: 1,
refetchInterval: REFRESH_MS,
});
return {
loading: query.isLoading,
// A failed refetch keeps the albums already loaded; the error shows only when there are none.
error: query.data ? null : (query.error as Error | null),
albums: query.data ?? [],
};
};

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { zoneView } from './zoneView';
describe('zoneView', () => {
it('opens albums by default and the timeline for an event deep link', () => {
expect(zoneView(undefined, undefined)).toBe('albums');
expect(zoneView(undefined, '$e')).toBe('timeline');
});
it('lets an event link win over an earlier choice of albums', () => {
expect(zoneView('albums', '$e')).toBe('timeline');
});
it('honours the choice when there is no event link', () => {
expect(zoneView('albums', undefined)).toBe('albums');
expect(zoneView('timeline', undefined)).toBe('timeline');
});
});

View file

@ -0,0 +1,5 @@
export type ZoneView = 'albums' | 'timeline';
/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */
export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView =>
eventId ? 'timeline' : choice ?? 'albums';

View file

@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Box, Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
@ -23,6 +23,10 @@ import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoom } from '../../hooks/useRoom';
import { AlbumView } from '../albums/AlbumView';
import * as albumCss from '../albums/AlbumView.css';
import { useIsFileZone } from '../albums/useAlbums';
import { ZoneView, zoneView } from '../albums/zoneView';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@ -64,6 +68,12 @@ export function RoomView({ eventId }: { eventId?: string }) {
const room = useRoom();
const { roomId } = room;
const editor = useEditor();
const isZone = useIsFileZone(room);
// A zone shows its albums; the timeline stays one tap away, and an event deep link opens it.
const [choice, setChoice] = useState<{ roomId: string; view: ZoneView }>();
const fileZone =
isZone &&
zoneView(choice && choice.roomId === roomId ? choice.view : undefined, eventId) === 'albums';
const mx = useMatrixClient();
@ -78,7 +88,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
if (fileZone || editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
@ -87,13 +97,35 @@ export function RoomView({ eventId }: { eventId?: string }) {
ReactEditor.focus(editor);
}
},
[editor]
[editor, fileZone]
)
);
// The file zone is its own view: no timeline, no composer.
if (fileZone) {
return (
<Page ref={roomViewRef}>
<AlbumView
key={roomId}
room={room}
onTimeline={() => setChoice({ roomId, view: 'timeline' })}
/>
</Page>
);
}
return (
<Page ref={roomViewRef}>
<Box grow="Yes" direction="Column">
{isZone && !eventId && (
<button
type="button"
className={albumCss.Back}
onClick={() => setChoice({ roomId, view: 'albums' })}
>
<Text size="B400">‹ Albums</Text>
</button>
)}
<RoomTimeline
key={roomId}
room={room}

View file

@ -26,6 +26,9 @@ export type ClientConfig = {
// Per-Mind notification earcons (src/app/features/dings). Off unless `enabled: true`.
dings?: DingsConfig;
// Rooms (ids or aliases) shown as the album view; a room whose create type is org.gamecult.file_zone needs no entry.
fileZoneRooms?: string[];
// Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo).
previewDemo?: boolean;
};

View file

@ -44,6 +44,8 @@ export type DemoRoom = {
topic?: string;
members: string[];
notice?: 'show' | 'study' | 'train';
/** The m.room.create `type`. */
type?: string;
parent?: string;
steward?: { mind: string; repo: string };
joined: boolean;
@ -54,6 +56,7 @@ export const ROOT = `!root:${DEMO_SERVER}`;
export const PROJECTS = `!projects:${DEMO_SERVER}`;
export const GENERAL = `!general:${DEMO_SERVER}`;
export const COMMONS = `!commons:${DEMO_SERVER}`;
export const FILES = `!files:${DEMO_SERVER}`;
const everyone = USERS.map((u) => u.id);
export const MIND_IDS = [user('eureka'), user('void'), user('nibu')];
@ -115,6 +118,17 @@ export const ROOMS: DemoRoom[] = [
{ from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 },
],
},
{
id: FILES,
alias: `#files:${DEMO_SERVER}`,
name: 'Files',
topic: 'The file zone: one thread per Mind, shown as albums.',
type: 'org.gamecult.file_zone',
members: [ME, ...minds],
parent: ROOT,
joined: true,
lines: [],
},
{
id: PROJECTS,
alias: `#projects:${DEMO_SERVER}`,
@ -302,6 +316,36 @@ export const ROOMS: DemoRoom[] = [
},
];
// The file zone's albums: a thread root per album, one thread reply per file. Media is generated
// in media.ts: audio by tone frequency, images by gradient color.
export type DemoAlbum = {
mind: string;
title: string;
minutesAgo: number;
files: { kind: 'audio' | 'image'; name: string; seed: number | [number, number, number] }[];
};
export const ALBUMS: DemoAlbum[] = [
{
mind: user('void'),
title: 'void',
minutesAgo: 90,
files: [
{ kind: 'audio', name: 'low-tone.wav', seed: 220 },
{ kind: 'audio', name: 'mid-tone.wav', seed: 330 },
{ kind: 'audio', name: 'high-tone.wav', seed: 440 },
],
},
{
mind: user('nibu'),
title: 'nibu',
minutesAgo: 30,
files: [
{ kind: 'image', name: 'dusk.png', seed: [200, 90, 60] },
{ kind: 'image', name: 'lagoon.png', seed: [40, 150, 190] },
],
},
];
// A Mind's canned answer to whatever the visitor types: label and line.
export const REPLIES: { pose: string; body: string }[] = [
{ pose: 'thinking', body: 'Hm. Let me think about that.' },

View file

@ -73,6 +73,12 @@ export const guardedFetch =
body: parseBody(raw),
signal: init?.signal ?? req?.signal ?? undefined,
});
if (out.raw) {
return new Response(out.raw.data, {
status: out.status,
headers: { 'Content-Type': out.raw.type },
});
}
return new Response(JSON.stringify(out.body), {
status: out.status,
headers: { 'Content-Type': 'application/json' },

95
src/preview-demo/media.ts Normal file
View file

@ -0,0 +1,95 @@
/* eslint-disable no-bitwise */
// Tiny media for the pretend server's file zone, generated here so the repo holds no binaries.
export type DemoMedia = { type: string; data: Uint8Array };
const u32 = (n: number): number[] => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
const le = (n: number, bytes: number): number[] =>
Array.from({ length: bytes }, (_, i) => (n >>> (8 * i)) & 255);
/** A 0.6 s mono 8 kHz 8-bit WAV: a sine at `hz` with a soft fade. */
export const wavTone = (hz: number): DemoMedia => {
const rate = 8000;
const n = Math.round(rate * 0.6);
const pcm = Array.from({ length: n }, (_, i) => {
const fade = Math.min(1, i / 400, (n - i) / 400);
return Math.round(128 + 90 * fade * Math.sin((2 * Math.PI * hz * i) / rate));
});
const tag = (s: string) => Array.from(s, (c) => c.charCodeAt(0));
return {
type: 'audio/wav',
data: Uint8Array.from([
...tag('RIFF'),
...le(36 + n, 4),
...tag('WAVEfmt '),
...le(16, 4),
...le(1, 2),
...le(1, 2),
...le(rate, 4),
...le(rate, 4),
...le(1, 2),
...le(8, 2),
...tag('data'),
...le(n, 4),
...pcm,
]),
};
};
const crc32 = (bytes: number[]): number => {
let c = ~0;
bytes.forEach((b) => {
c ^= b;
for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
});
return ~c >>> 0;
};
const chunk = (name: string, data: number[]): number[] => {
const body = [...Array.from(name, (c) => c.charCodeAt(0)), ...data];
return [...u32(data.length), ...body, ...u32(crc32(body))];
};
/** A 32 x 32 RGB PNG: a diagonal gradient in `rgb`, zlib "stored" blocks, no compression. */
export const pngGradient = (rgb: [number, number, number]): DemoMedia => {
const size = 32;
const raw: number[] = [];
for (let y = 0; y < size; y += 1) {
raw.push(0);
for (let x = 0; x < size; x += 1) {
const f = 0.35 + (0.65 * (x + y)) / (2 * size);
raw.push(...rgb.map((v) => Math.round(v * f)));
}
}
let a = 1;
let b = 0;
raw.forEach((v) => {
a = (a + v) % 65521;
b = (b + a) % 65521;
});
// One stored block holds up to 65535 bytes; 32 * 97 = 3104 fits.
const zlib = [
0x78,
0x01,
1,
...le(raw.length, 2),
...le(~raw.length & 0xffff, 2),
...raw,
...u32(((b << 16) | a) >>> 0),
];
return {
type: 'image/png',
data: Uint8Array.from([
137,
80,
78,
71,
13,
10,
26,
10,
...chunk('IHDR', [...u32(size), ...u32(size), 8, 2, 0, 0, 0]),
...chunk('IDAT', zlib),
...chunk('IEND', []),
]),
};
};

View file

@ -1,7 +1,14 @@
/* eslint-disable max-classes-per-file, no-promise-executor-return, no-useless-constructor, no-empty-function, no-void, @typescript-eslint/no-non-null-assertion */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk';
import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures';
import {
ClientEvent,
ClientPrefix,
MatrixClient,
Method,
SyncState,
createClient,
} from 'matrix-js-sdk';
import { ALBUMS, DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS, FILES } from './fixtures';
import { dropForeignSession, guardedFetch, installDemo, routeOf } from './install';
import { createDemoServer, DemoServer } from './server';
import { seedDemoSettings } from './seed';
@ -30,6 +37,9 @@ const waitFor = <T>(read: () => T | undefined, ms = 5000): Promise<T> =>
tick();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Ev = { event_id: string; content: Record<string, any> };
const live: { server: DemoServer; mx: MatrixClient }[] = [];
afterEach(() => {
live.splice(0).forEach(({ server, mx }) => {
@ -116,7 +126,12 @@ describe('the pretend homeserver through matrix-js-sdk', () => {
const ev = mx.getRoom(r.id)!.currentState.getStateEvents('org.gamecult.notice', '');
return [r.name, roomTrainingLine(parseRoomNotice(ev?.getContent())) !== undefined];
});
expect(Object.fromEntries(lines)).toEqual({ General: true, Ratatoskr: false, Huginn: false });
expect(Object.fromEntries(lines)).toEqual({
General: true,
Files: false,
Ratatoskr: false,
Huginn: false,
});
});
it('sends a message, echoes it once with its transaction id, and a Mind answers with a pose', async () => {
@ -172,6 +187,56 @@ describe('the pretend homeserver through matrix-js-sdk', () => {
expect(stewards.filter(Boolean).length).toBe(2);
});
it('syncs the file zone as a joined room whose create type marks it', async () => {
const { mx } = await syncedClient();
const create = mx.getRoom(FILES)!.currentState.getStateEvents('m.room.create', '')!;
expect(create.getContent().type).toBe('org.gamecult.file_zone');
expect(
mx.getRoom(GENERAL)!.currentState.getStateEvents('m.room.create', '')!.getContent().type
).toBeUndefined();
});
it('serves the zone albums through the thread endpoints and their media as bytes', async () => {
const { mx } = await syncedClient();
const get = (path: string, params?: Record<string, string>) =>
mx.http.authedRequest<{ chunk: Ev[] }>(Method.Get, path, params, undefined, {
prefix: ClientPrefix.V1,
});
const roots = (await get(`/rooms/${encodeURIComponent(FILES)}/threads`)).chunk;
expect(roots.map((r) => r.content.body).sort()).toEqual(ALBUMS.map((a) => a.title).sort());
const counts: Record<string, string[]> = {};
await Promise.all(
roots.map(async (root: Ev) => {
const rel = `/rooms/${encodeURIComponent(FILES)}/relations/${encodeURIComponent(
root.event_id
)}/m.thread`;
const oldest = (await get(rel, { dir: 'f' })).chunk;
const newest = (await get(rel)).chunk;
expect(newest.map((e: Ev) => e.event_id)).toEqual(
oldest.map((e: Ev) => e.event_id).reverse()
);
counts[root.content.body] = oldest.map((e: Ev) => e.content.msgtype);
expect(oldest.every((e: Ev) => e.content['m.relates_to'].event_id === root.event_id)).toBe(
true
);
})
);
expect(counts).toEqual({
void: ['m.audio', 'm.audio', 'm.audio'],
nibu: ['m.image', 'm.image'],
});
const f = guardedFetch(createDemoServer(), PAGE, never as never);
const wav = await f(`${DEMO_ORIGIN}/_matrix/client/v1/media/download/demo.invalid/album0file0`);
expect(wav.headers.get('content-type')).toBe('audio/wav');
expect(new TextDecoder().decode((await wav.arrayBuffer()).slice(0, 4))).toBe('RIFF');
const png = await f(`${DEMO_ORIGIN}/_matrix/media/v3/download/demo.invalid/album1file1`);
expect(png.headers.get('content-type')).toBe('image/png');
expect((await png.arrayBuffer()).byteLength).toBeGreaterThan(100);
expect(
(await f(`${DEMO_ORIGIN}/_matrix/client/v1/media/download/demo.invalid/nope`)).status
).toBe(404);
});
it('answers a long poll with nothing new after its timeout', async () => {
const server = createDemoServer();
live.push({ server, mx: clientFor(server) });

View file

@ -3,6 +3,7 @@
// send messages and get a Mind's canned answer. It touches no network and no storage; the
// fetch guard that routes requests here lives in install.ts.
import {
ALBUMS,
COMMONS,
DEMO_DEVICE,
DEMO_ORIGIN,
@ -10,14 +11,17 @@ import {
DEMO_TOKEN,
DemoEvent,
DemoRoom,
FILES,
ME,
MIND_IDS,
REPLIES,
ROOMS,
USERS,
} from './fixtures';
import { DemoMedia, pngGradient, wavTone } from './media';
export type DemoResponse = { status: number; body: unknown };
/** `raw` answers a media download with bytes instead of JSON. */
export type DemoResponse = { status: number; body: unknown; raw?: DemoMedia };
export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal };
const POSE_KEY = 'org.gamecult.pose';
@ -79,7 +83,12 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
mk(
'm.room.create',
creator,
room.space ? { type: 'm.space', room_version: '10' } : { room_version: '10' },
{
room_version: '10',
...((room.space ? 'm.space' : room.type)
? { type: room.space ? 'm.space' : room.type }
: {}),
},
t0,
''
),
@ -159,6 +168,45 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
[`@eureka:${DEMO_SERVER}`]: ROOMS.filter((r) => r.direct).map((r) => r.id),
});
// The file zone's albums live outside the sync timeline: thread roots per room, replies per
// root, media by mxc id. The thread endpoints and media downloads answer from these.
const threadRoots = new Map<string, DemoEvent[]>();
const threadReplies = new Map<string, DemoEvent[]>();
const media = new Map<string, DemoMedia>();
ALBUMS.forEach((album, a) => {
const t0 = now() - album.minutesAgo * 60 * 1000;
const root = mk('m.room.message', album.mind, { msgtype: 'm.text', body: album.title }, t0);
threadRoots.set(FILES, [...(threadRoots.get(FILES) ?? []), root]);
threadReplies.set(
root.event_id,
album.files.map((f, i) => {
const id = `album${a}file${i}`;
const m =
f.kind === 'audio'
? wavTone(f.seed as number)
: pngGradient(f.seed as [number, number, number]);
media.set(id, m);
return mk(
'm.room.message',
album.mind,
{
msgtype: f.kind === 'audio' ? 'm.audio' : 'm.image',
body: f.name,
url: `mxc://${DEMO_SERVER}/${id}`,
info: { mimetype: m.type, size: m.data.length },
'm.relates_to': {
rel_type: 'm.thread',
event_id: root.event_id,
is_falling_back: true,
'm.in_reply_to': { event_id: root.event_id },
},
},
t0 + (i + 1) * 60 * 1000
);
})
);
});
const notify = () => {
waiters.forEach((w) => w());
waiters.clear();
@ -290,6 +338,11 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
if (p === '/.well-known/matrix/client')
return ok({ 'm.homeserver': { base_url: DEMO_ORIGIN } });
if (!p.startsWith('/_matrix/')) return err(404, 'M_NOT_FOUND', 'Not found');
g = p.match(/^\/_matrix\/(?:client\/v1\/media|media\/v3)\/download\/[^/]+\/([^/]+)$/);
if (g) {
const found = media.get(g[1]);
return found ? { status: 200, body: null, raw: found } : err(404, 'M_NOT_FOUND', 'No media');
}
const c = p.replace(/^\/_matrix\/client\/(v\d+|r0|unstable)/, '');
if (p.endsWith('/versions'))
return ok({ versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
@ -394,6 +447,13 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
if (g) {
return ok({ chunk: (stateOf.get(g[1]) ?? []).filter((e) => e.type === 'm.room.member') });
}
g = c.match(/^\/rooms\/([^/]+)\/threads$/);
if (g) return ok({ chunk: [...(threadRoots.get(g[1]) ?? [])].reverse() });
g = c.match(/^\/rooms\/([^/]+)\/relations\/([^/]+)\/m\.thread$/);
if (g) {
const oldestFirst = [...(threadReplies.get(g[2]) ?? [])];
return ok({ chunk: u.searchParams.get('dir') === 'f' ? oldestFirst : oldestFirst.reverse() });
}
g = c.match(/^\/rooms\/([^/]+)\/messages$/);
if (g) return ok({ chunk: [], start: 'p0', end: 'p0' });
g = c.match(/^\/rooms\/([^/]+)\/(?:summary|context\/.+)$/);