diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 98696766..361624bd 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -82,7 +82,7 @@ What it buys: one identity, one audience, one audit trail, immutable files, exis - **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know. - **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk. - **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes. -- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table. +- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away. - **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice. - **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`. @@ -123,7 +123,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C 1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. 2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. -4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it. +4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). 5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 73e63e7c..944cc761 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -68,6 +68,26 @@ function FileRow({ room, file, author }: FileRowProps) { ); } +/** A file, and under it a row that unfolds the versions it supersedes. */ +function VersionedFileRow({ room, file, author }: FileRowProps) { + const [shown, setShown] = useState(false); + const n = file.earlier.length; + return ( + <> + + {n > 0 && ( + + )} + {shown && + file.earlier.map((e) => )} + + ); +} + const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`; /** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */ @@ -124,7 +144,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {open.count === 0 && This album has no files.}
{open.files.map((f) => ( - + ))}
diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index e98e4f51..3053a493 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -215,6 +215,30 @@ describe('buildAlbums', () => { }); }); +describe('versions', () => { + const v = (id: string, ts: number, replaces?: string) => + reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + + it('lists the latest of a chain and folds the earlier versions under it', () => { + const a = one(root('$r', 1), [v('$v1', 10), v('$v2', 20, '$v1'), reply('$x', 30, 'm.file')]); + expect(a.files.map((f) => [f.id, f.earlier.map((e) => e.id)])).toEqual([ + ['$v2', ['$v1']], + ['$x', []], + ]); + expect(a.count).toBe(2); + }); +}); + +describe('versions: cover and sender', () => { + it('the cover comes from the shown version, not a folded image', () => { + const img = (id: string, ts: number, type: string, replaces?: string) => + reply(id, ts, type, id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + const a = one(root('$r', 1), [img('$v1', 10, 'm.image'), img('$v2', 20, 'm.file', '$v1')]); + expect(a.files.map((f) => f.id)).toEqual(['$v2']); + expect(a.cover).toBeUndefined(); + }); +}); + describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 6fe8a452..636ede38 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -3,6 +3,7 @@ // into albums. See docs/file-zone.md for the rulings this encodes. import { readersOf } from './fileReceipt'; import type { FileReader } from './fileReceipt'; +import { foldVersions } from './versions'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -22,11 +23,14 @@ export type AlbumFile = { id: string; kind: FileKind; name: string; + sender: string; ts: number; /** The event content, as RenderMessageContent reads it. */ content: Record; /** Receipts in this album that name this file, one per sender (the latest), oldest first. */ readBy: FileReader[]; + /** The versions this file supersedes, nearest first (`org.gamecult.publish.replaces`). */ + earlier: AlbumFile[]; }; export type Album = { @@ -72,9 +76,11 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { id: e.event_id, kind, name: asString(e.content.filename) || asString(e.content.body), + sender: e.sender ?? '', ts: tsOf(e.origin_server_ts), content: e.content, readBy: [], + earlier: [], }; }; @@ -103,14 +109,15 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { }) .sort((a, b) => a.ts - b.ts) .map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) })); + const heads = foldVersions(files); 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, + cover: heads.find((f) => f.kind === 'image'), + files: heads, + count: heads.length, }; }; diff --git a/src/app/features/albums/versions.test.ts b/src/app/features/albums/versions.test.ts new file mode 100644 index 00000000..e8ae64b8 --- /dev/null +++ b/src/app/features/albums/versions.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { PUBLISH_KEY, foldVersions, replacesOf } from './versions'; + +type F = { id: string; sender: string; content: Record; earlier: F[] }; +const f = (id: string, replaces?: string, sender = '@m:x'): F => ({ + id, + sender, + content: replaces ? { [PUBLISH_KEY]: { replaces } } : {}, + earlier: [], +}); +const shape = (files: F[]): unknown => files.map((x) => [x.id, x.earlier.map((e) => e.id)]); + +describe('replacesOf', () => { + it('reads the id the publisher key names', () => { + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '$a' } })).toBe('$a'); + }); + it('anything else says nothing', () => { + expect(replacesOf({})).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: {} })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: 3 } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '' } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: 'x' })).toBeUndefined(); + }); +}); + +describe('foldVersions', () => { + it('files that replace nothing pass through in order, with nothing folded', () => { + expect(shape(foldVersions([f('$a'), f('$b')]))).toEqual([ + ['$a', []], + ['$b', []], + ]); + }); + + it('shows the latest of a chain with earlier versions nearest first', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$b')]))).toEqual([ + ['$c', ['$b', '$a']], + ]); + }); + + it('keeps unrelated files beside a chain, in order', () => { + expect(shape(foldVersions([f('$a'), f('$x'), f('$b', '$a')]))).toEqual([ + ['$x', []], + ['$b', ['$a']], + ]); + }); + + it('a replacement whose target is not in the list stands alone', () => { + expect(shape(foldVersions([f('$b', '$gone')]))).toEqual([['$b', []]]); + }); + + it('a file two others replace sits under both', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$a')]))).toEqual([ + ['$b', ['$a']], + ['$c', ['$a']], + ]); + }); + + it('a malformed loop shows no head and does not hang', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$s', '$s')]); + expect(out.map((x) => x.id)).toEqual(['$s']); + }); + + it('a loop reached from a head is walked once and ends', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$h', '$a')]); + expect(shape(out)).toEqual([['$h', ['$a', '$b']]]); + }); + + it('a replacement by another sender folds nothing', () => { + const out = foldVersions([f('$victim', undefined, '@m:x'), f('$evil', '$victim', '@e:x')]); + expect(shape(out)).toEqual([ + ['$victim', []], + ['$evil', []], + ]); + }); + + it('an empty list is empty', () => { + expect(foldVersions([])).toEqual([]); + }); +}); diff --git a/src/app/features/albums/versions.ts b/src/app/features/albums/versions.ts new file mode 100644 index 00000000..8c7055db --- /dev/null +++ b/src/app/features/albums/versions.ts @@ -0,0 +1,50 @@ +// Pure: a file event may name the event it supersedes in `org.gamecult.publish.replaces` (the +// publisher writes it on a later final version; docs/commons-files-desk.md, "replaces, not +// m.replace"). Both events stay ordinary files for every other client; this view shows the latest +// of a chain with the earlier ones folded under it. A chain is read from the events alone. + +/** Key the publisher writes its provenance under. */ +export const PUBLISH_KEY = 'org.gamecult.publish'; + +/** The event id a file says it replaces, if it says one. */ +export const replacesOf = (content: Record): string | undefined => { + const id = (content[PUBLISH_KEY] as { replaces?: unknown } | undefined)?.replaces; + return typeof id === 'string' && id ? id : undefined; +}; + +type Versioned = F & { earlier: F[] }; + +/** + * Files not replaced by another file in the list, oldest first as given, each carrying the files it + * supersedes (nearest first) in `earlier`. A replacement whose target is missing from the list (a + * redacted event, another thread) replaces nothing and stands as a head. A replacement by another sender is ignored. A file two others claim to + * replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (an event id hashes its content, so a real one cannot cycle). + */ +export const foldVersions = < + F extends { id: string; sender: string; content: Record } +>( + files: Versioned[] +): Versioned[] => { + const byId = new Map(files.map((f) => [f.id, f])); + const target = (f: F): F | undefined => { + const id = replacesOf(f.content); + const t = id && id !== f.id ? byId.get(id) : undefined; + // Only the sender of a file can supersede it, so no one else in the thread can fold it away. + return t && t.sender === f.sender ? t : undefined; + }; + const replaced = new Set(); + files.forEach((f) => { + const t = target(f); + if (t) replaced.add(t.id); + }); + const chainOf = (head: Versioned): Versioned[] => { + const out: Versioned[] = []; + const seen = new Set([head.id]); + for (let t = target(head); t && !seen.has(t.id); t = target(t)) { + seen.add(t.id); + out.push({ ...t, earlier: [] }); + } + return out; + }; + return files.filter((f) => !replaced.has(f.id)).map((h) => ({ ...h, earlier: chainOf(h) })); +};