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) }));
+};