diff --git a/src/app/features/albums/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts new file mode 100644 index 00000000..c4f4fc79 --- /dev/null +++ b/src/app/features/albums/AlbumView.css.ts @@ -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 }); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx new file mode 100644 index 00000000..28e0c477 --- /dev/null +++ b/src/app/features/albums/AlbumView.tsx @@ -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) => { + 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 ( + + + {file.name} + + {audio ? ( + } /> + ) : ( + () => file.content as T} + mediaAutoLoad={mediaAutoLoad} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={LINKIFY_OPTS} + /> + )} + + ); +} + +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(); + 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 = Loading albums…; + else if (error) body = Could not load albums: {error.message}; + else if (open) { + const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image'); + body = ( + <> + + {open.title} + + {authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)} + + {open.count === 0 && This album has no files.} +
+ {open.files.map((f) => ( + + ))} +
+ + ); + } else if (encrypted) + body = This zone is encrypted, so its albums cannot be listed here.; + else if (albums.length === 0) body = No albums yet.; + else { + body = albums.map((a) => ( + + )); + } + + return ( + +
+ {body} + +
+
+ ); +} diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts new file mode 100644 index 00000000..3a27bed6 --- /dev/null +++ b/src/app/features/albums/buildAlbums.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts new file mode 100644 index 00000000..8e57262b --- /dev/null +++ b/src/app/features/albums/buildAlbums.ts @@ -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; + unsigned?: Record; +}; + +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; +}; + +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([ + ['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 } => + !!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 => { + const relations = root.unsigned?.['m.relations'] as Record | undefined; + const edit = relations?.['m.replace'] as ZoneEvent | undefined; + const edited = (edit?.content?.['m.new_content'] as Record | 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(); + 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(); + 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); +}; diff --git a/src/app/features/albums/isFileZone.test.ts b/src/app/features/albums/isFileZone.test.ts new file mode 100644 index 00000000..0e55269f --- /dev/null +++ b/src/app/features/albums/isFileZone.test.ts @@ -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); + }); +}); diff --git a/src/app/features/albums/isFileZone.ts b/src/app/features/albums/isFileZone.ts new file mode 100644 index 00000000..071ef17b --- /dev/null +++ b/src/app/features/albums/isFileZone.ts @@ -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)); diff --git a/src/app/features/albums/mapLimit.test.ts b/src/app/features/albums/mapLimit.test.ts new file mode 100644 index 00000000..24898cb0 --- /dev/null +++ b/src/app/features/albums/mapLimit.test.ts @@ -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'); + }); +}); diff --git a/src/app/features/albums/mapLimit.ts b/src/app/features/albums/mapLimit.ts new file mode 100644 index 00000000..e850add0 --- /dev/null +++ b/src/app/features/albums/mapLimit.ts @@ -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 ( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise => { + const out = new Array(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; +}; diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts new file mode 100644 index 00000000..a01f1efb --- /dev/null +++ b/src/app/features/albums/useAlbums.ts @@ -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 => { + const get = (path: string, params: Record) => + mx.http.authedRequest(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 ?? [], + }; +}; diff --git a/src/app/features/albums/zoneView.test.ts b/src/app/features/albums/zoneView.test.ts new file mode 100644 index 00000000..65beb9cb --- /dev/null +++ b/src/app/features/albums/zoneView.test.ts @@ -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'); + }); +}); diff --git a/src/app/features/albums/zoneView.ts b/src/app/features/albums/zoneView.ts new file mode 100644 index 00000000..1af7f705 --- /dev/null +++ b/src/app/features/albums/zoneView.ts @@ -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'; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 3fbd81fa..0137fd95 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -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 ( + + setChoice({ roomId, view: 'timeline' })} + /> + + ); + } + return ( + {isZone && !eventId && ( + + )} 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.' }, diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index 85acc9a9..54bddd71 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -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' }, diff --git a/src/preview-demo/media.ts b/src/preview-demo/media.ts new file mode 100644 index 00000000..cfde5669 --- /dev/null +++ b/src/preview-demo/media.ts @@ -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', []), + ]), + }; +}; diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts index ec9031c1..0b98e90d 100644 --- a/src/preview-demo/server.test.ts +++ b/src/preview-demo/server.test.ts @@ -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 = (read: () => T | undefined, ms = 5000): Promise => tick(); }); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Ev = { event_id: string; content: Record }; + 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) => + 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 = {}; + 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) }); diff --git a/src/preview-demo/server.ts b/src/preview-demo/server.ts index 33a51b16..ed8cbf46 100644 --- a/src/preview-demo/server.ts +++ b/src/preview-demo/server.ts @@ -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(); + const threadReplies = new Map(); + const media = new Map(); + 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\/.+)$/);