Files desk cut 3: versions via org.gamecult.publish.replaces #149
6 changed files with 186 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<FileRow room={room} file={file} author={author} />
|
||||
{n > 0 && (
|
||||
<button type="button" className={css.Row} onClick={() => setShown(!shown)}>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{`${shown ? '▾' : '▸'} ${n} earlier ${n === 1 ? 'version' : 'versions'}`}
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
{shown &&
|
||||
file.earlier.map((e) => <FileRow key={e.id} room={room} file={e} author={author} />)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 && <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)} />
|
||||
<VersionedFileRow key={f.id} room={room} file={f} author={authorOf(open)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
/** 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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
79
src/app/features/albums/versions.test.ts
Normal file
79
src/app/features/albums/versions.test.ts
Normal file
|
|
@ -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<string, unknown>; 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([]);
|
||||
});
|
||||
});
|
||||
50
src/app/features/albums/versions.ts
Normal file
50
src/app/features/albums/versions.ts
Normal file
|
|
@ -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, unknown>): string | undefined => {
|
||||
const id = (content[PUBLISH_KEY] as { replaces?: unknown } | undefined)?.replaces;
|
||||
return typeof id === 'string' && id ? id : undefined;
|
||||
};
|
||||
|
||||
type Versioned<F> = 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<string, unknown> }
|
||||
>(
|
||||
files: Versioned<F>[]
|
||||
): Versioned<F>[] => {
|
||||
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<string>();
|
||||
files.forEach((f) => {
|
||||
const t = target(f);
|
||||
if (t) replaced.add(t.id);
|
||||
});
|
||||
const chainOf = (head: Versioned<F>): Versioned<F>[] => {
|
||||
const out: Versioned<F>[] = [];
|
||||
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) }));
|
||||
};
|
||||
Loading…
Reference in a new issue