Albums slice 1: read-only album view for the file zone #70
18 changed files with 1022 additions and 8 deletions
51
src/app/features/albums/AlbumView.css.ts
Normal file
51
src/app/features/albums/AlbumView.css.ts
Normal 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 });
|
||||
141
src/app/features/albums/AlbumView.tsx
Normal file
141
src/app/features/albums/AlbumView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/app/features/albums/buildAlbums.test.ts
Normal file
216
src/app/features/albums/buildAlbums.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
src/app/features/albums/buildAlbums.ts
Normal file
121
src/app/features/albums/buildAlbums.ts
Normal 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);
|
||||
};
|
||||
24
src/app/features/albums/isFileZone.test.ts
Normal file
24
src/app/features/albums/isFileZone.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
src/app/features/albums/isFileZone.ts
Normal file
14
src/app/features/albums/isFileZone.ts
Normal 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));
|
||||
29
src/app/features/albums/mapLimit.test.ts
Normal file
29
src/app/features/albums/mapLimit.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
19
src/app/features/albums/mapLimit.ts
Normal file
19
src/app/features/albums/mapLimit.ts
Normal 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;
|
||||
};
|
||||
73
src/app/features/albums/useAlbums.ts
Normal file
73
src/app/features/albums/useAlbums.ts
Normal 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 ?? [],
|
||||
};
|
||||
};
|
||||
16
src/app/features/albums/zoneView.test.ts
Normal file
16
src/app/features/albums/zoneView.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
5
src/app/features/albums/zoneView.ts
Normal file
5
src/app/features/albums/zoneView.ts
Normal 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';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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
95
src/preview-demo/media.ts
Normal 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', []),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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\/.+)$/);
|
||||
|
|
|
|||
Loading…
Reference in a new issue