Files desk: Stop a send in flight, show replaces loops, freshest last event on add #151

Merged
void merged 2 commits from void/files-desk-send-cancel into dev 2026-11-13 23:17:43 +00:00
14 changed files with 274 additions and 42 deletions

View file

@ -124,7 +124,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C
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 (done, PR #149).
5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); cancel of a send in flight is not, and needs an abort path through `uploadContent`.
5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`.
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.

View file

@ -120,4 +120,4 @@ Client, `src/app/features/albums`:
Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts.
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; cancel of a send in flight (a pick can be cancelled before it goes). Built since: pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).

View file

@ -14,7 +14,7 @@ import { markAsRead } from '../../utils/notifications';
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile, lastEventId } from './buildAlbums';
import { Album, AlbumFile, lastEvent } from './buildAlbums';
import { folderOf, listFolder, pathLabel } from './albumFolders';
import { FileReadBy } from './FileReadBy';
import { ShareSet, useShareEnabled } from './ShareSet';
@ -146,7 +146,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
<ShareSet
key={open.id}
room={room}
into={{ albumId: open.id, lastId: lastEventId(open) }}
into={{ albumId: open.id, lastId: lastEvent(open).id, lastTs: lastEvent(open).ts }}
/>
)}
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>

View file

@ -27,8 +27,8 @@ vi.mock('./shareController', () => ({
}));
vi.mock('./shareSet', async (importOriginal) => ({
...(await importOriginal<typeof import('./shareSet')>()),
sendShareSet: async (_port: unknown, into: string | { albumId: string }) => {
captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}`);
sendShareSet: async (_port: unknown, into: string | { albumId: string; lastId: string }) => {
captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}@${into.lastId}`);
return { albumId: '$a', sent: 0, failed: [], failedAt: [] };
},
}));
@ -42,8 +42,19 @@ vi.mock('react', async (importOriginal) => {
// eslint-disable-next-line import/first
import { ShareSet } from './ShareSet';
const mount = (folder?: string[], into?: { albumId: string; lastId: string }) => {
const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never;
const timeline: unknown[] = [];
const ev = (id: string, ts: number, threadRootId?: string, status: string | null = null) => ({
getId: () => id,
getTs: () => ts,
threadRootId,
status,
});
const mount = (folder?: string[], into?: { albumId: string; lastId: string; lastTs: number }) => {
const room = {
roomId: '!r:x',
hasEncryptionStateEvent: () => false,
getLiveTimeline: () => ({ getEvents: () => timeline }),
} as never;
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<MatrixClientProvider value={{ sendMessage: async () => ({ event_id: '$e' }) } as never}>
@ -61,6 +72,7 @@ describe('ShareSet: the folder reaches the album title', () => {
beforeEach(() => {
captured.run = undefined;
captured.titles.length = 0;
timeline.length = 0;
});
it('at the top, the typed title goes as typed', async () => {
@ -84,7 +96,19 @@ describe('ShareSet: the folder reaches the album title', () => {
});
it('an existing album takes the files instead of a title, whatever the folder', async () => {
mount(['a'], { albumId: '$alb', lastId: '$last' });
expect(await send('ignored')).toBe('into:$alb');
mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 });
expect(await send('ignored')).toBe('into:$alb@$last');
});
it('the freshest sent event of that thread in the room timeline is where the files thread after', async () => {
timeline.push(
ev('$other', 99, '$else'),
ev('$local', 98, '$alb', 'sending'),
ev('$new', 50, '$alb'),
ev('$sentNotEchoed', 60, '$alb', 'sent'),
ev('$failed', 70, '$alb', 'not_sent')
);
mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 });
expect(await send('x')).toBe('into:$alb@$sentNotEchoed');
});
});

View file

@ -23,6 +23,7 @@ const view = (over: Partial<ShareState<File>> = {}, adding = false) =>
onTitle={noop}
onSend={noop}
onCancel={noop}
onStop={noop}
adding={adding}
/>
);
@ -55,6 +56,7 @@ describe('the share panel, drawn', () => {
phase: { kind: 'sending', done: 2 },
});
expect(text(html)).toContain('Sending 2 of 3…');
expect(text(html)).toContain('Stop');
expect(text(html)).not.toContain('Send 3 files');
expect(text(html)).not.toContain('Cancel');
expect(html).toMatch(

View file

@ -1,7 +1,7 @@
import React, { useRef, useState, useSyncExternalStore } from 'react';
import { Text } from 'folds';
import { useQueryClient } from '@tanstack/react-query';
import { Room } from 'matrix-js-sdk';
import { EventStatus, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { uploadContent } from '../../utils/matrix';
@ -10,7 +10,7 @@ import { getMsgContent } from '../room/msgContent';
import { FILE_HASH_KEY, hashForSend } from './fileReceipt';
import { ShareController, ShareState, createShareController } from './shareController';
import { titleIn } from './albumFolders';
import { DEFAULT_TITLE, ExistingAlbum, sendShareSet } from './shareSet';
import { DEFAULT_TITLE, ExistingAlbum, freshest, sendShareSet, ThreadEvent } from './shareSet';
import * as css from './AlbumView.css';
/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */
@ -29,6 +29,7 @@ type ShareViewProps = {
onTitle: (title: string) => void;
onSend: () => void;
onCancel: () => void;
onStop: () => void;
/** Adding to an existing album: the files join it, so there is no title to ask for. */
adding?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
@ -45,6 +46,7 @@ export function ShareSetView({
onTitle,
onSend,
onCancel,
onStop,
adding,
inputRef,
}: ShareViewProps) {
@ -85,9 +87,14 @@ export function ShareSetView({
{files.map((f) => f.name).join(' · ')}
</Text>
{phase.kind === 'sending' ? (
<Text size="T300">
Sending {phase.done} of {files.length}…
</Text>
<>
<Text size="T300">
Sending {phase.done} of {files.length}…
</Text>
<button type="button" className={css.Row} onClick={onStop}>
<Text size="B400">Stop</Text>
</button>
</>
) : (
<>
<button type="button" className={css.Row} onClick={onSend}>
@ -106,6 +113,14 @@ export function ShareSetView({
);
}
/** The sent events in the room's live timeline, as the thread view of them `freshest` reads. */
const threadEvents = (room: Room): ThreadEvent[] =>
room
.getLiveTimeline()
.getEvents()
.filter((e) => (!e.status || e.status === EventStatus.SENT) && !!e.getId())
.map((e) => ({ id: e.getId() as string, ts: e.getTs(), threadRoot: e.threadRootId }));
type ShareSetProps = {
room: Room;
/** The path a new album lands in: its title is prefixed by it (docs/commons-files-desk.md). */
@ -127,15 +142,27 @@ export function ShareSet({ room, folder = [], into }: ShareSetProps) {
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
onSent: () =>
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
run: (files, title, onFile) =>
run: (files, title, onFile, signal) =>
sendShareSet(
{
signal,
send: async (content) =>
(await live.current.mx.sendMessage(room.roomId, content as any)).event_id,
contentOf: async (f) => {
const file = safeFile(f.file);
const mxc = await new Promise<string>((resolve, reject) => {
uploadContent(live.current.mx, file, { onSuccess: resolve, onError: reject });
const { mx: client } = live.current;
let upload: Promise<unknown> | undefined;
const abort = () => upload && client.cancelUpload(upload as never);
signal.addEventListener('abort', abort, { once: true });
uploadContent(client, file, {
onPromise: (p) => {
upload = p;
if (signal.aborted) abort();
},
onSuccess: resolve,
onError: reject,
});
});
const content = await getMsgContent(
live.current.mx,
@ -153,7 +180,9 @@ export function ShareSet({ room, folder = [], into }: ShareSetProps) {
},
onFile,
},
live.current.into ?? titleIn(live.current.folder, title, DEFAULT_TITLE),
live.current.into
? freshest(live.current.into, threadEvents(live.current.room))
: titleIn(live.current.folder, title, DEFAULT_TITLE),
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
),
})
@ -168,6 +197,7 @@ export function ShareSet({ room, folder = [], into }: ShareSetProps) {
onTitle={controller.setTitle}
onSend={controller.send}
onCancel={controller.cancel}
onStop={controller.stop}
adding={!!into}
/>
);

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { AlbumInput, ZoneEvent, buildAlbums, lastEventId } from './buildAlbums';
import { AlbumInput, ZoneEvent, buildAlbums, lastEvent } from './buildAlbums';
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
event_id: id,
@ -239,14 +239,15 @@ describe('versions: cover and sender', () => {
});
});
describe('lastEventId', () => {
describe('lastEvent', () => {
it('is the newest event of the thread, a folded version included; the root when empty', () => {
const v = (id: string, ts: number, replaces?: string) =>
reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {});
expect(lastEventId(one(root('$r', 1), [v('$a', 10), v('$b', 20, '$a'), v('$c', 15)]))).toBe(
'$b'
);
expect(lastEventId(one(root('$r', 1), []))).toBe('$r');
expect(lastEvent(one(root('$r', 1), [v('$a', 10), v('$b', 20, '$a'), v('$c', 15)]))).toEqual({
id: '$b',
ts: 20,
});
expect(lastEvent(one(root('$r', 1), []))).toEqual({ id: '$r', ts: 1 });
});
});

View file

@ -136,8 +136,10 @@ export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => {
};
/** The newest event in an album's thread (shown or folded), else its root: where a new file threads after. */
export const lastEventId = (album: Album): string =>
export const lastEvent = (album: Album): { id: string; ts: number } =>
album.files
.flatMap((f) => [f, ...f.earlier])
.reduce<AlbumFile | undefined>((a, f) => (!a || f.ts >= a.ts ? f : a), undefined)?.id ??
album.id;
.reduce<{ id: string; ts: number }>((a, f) => (f.ts >= a.ts ? { id: f.id, ts: f.ts } : a), {
id: album.id,
ts: album.ts,
});

View file

@ -7,11 +7,11 @@ const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], fa
/** A run the test settles by hand, so "in flight" is a state the test can stand in. */
const hand = () => {
const calls: { files: Picked[]; title: string; onFile: () => void }[] = [];
const calls: { files: Picked[]; title: string; onFile: () => void; signal: AbortSignal }[] = [];
let release: (r: ShareResult) => void = () => undefined;
let fail: (e: Error) => void = () => undefined;
const run: ShareDeps<Picked>['run'] = (files, title, onFile) => {
calls.push({ files, title, onFile });
const run: ShareDeps<Picked>['run'] = (files, title, onFile, signal) => {
calls.push({ files, title, onFile, signal });
return new Promise<ShareResult>((resolve, reject) => {
release = resolve;
fail = reject;
@ -259,3 +259,23 @@ describe('an encrypted room', () => {
});
});
});
describe('Stop', () => {
it('aborts the signal of the send in flight, and only that send', async () => {
const { c, h } = make();
c.pick([file('a'), file('b')]);
c.stop(); // nothing running: nothing to abort
const sending = c.send();
expect(h.calls[0].signal.aborted).toBe(false);
c.stop();
expect(h.calls[0].signal.aborted).toBe(true);
h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] });
await sending;
expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']);
// the next send gets a fresh signal
const again = c.send();
expect(h.calls[1].signal.aborted).toBe(false);
h.release(ok(2));
await again;
});
});

View file

@ -23,7 +23,7 @@ export type ShareDeps<F extends Picked> = {
roomId: string;
isEncrypted: () => boolean;
/** Sends the files as one album; reports each settled file through onFile. */
run: (files: F[], title: string, onFile: () => void) => Promise<ShareResult>;
run: (files: F[], title: string, onFile: () => void, signal: AbortSignal) => Promise<ShareResult>;
/** Called after a send that reached the room, whatever it left unsent. */
onSent?: () => void;
};
@ -34,6 +34,7 @@ const sending = new Set<string>();
export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
let state: ShareState<F> = { files: [], title: '', phase: { kind: 'idle' }, epoch: 0 };
const listeners = new Set<() => void>();
let running: AbortController | undefined;
const set = (patch: Partial<ShareState<F>>) => {
state = { ...state, ...patch };
listeners.forEach((l) => l());
@ -48,14 +49,21 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
return;
}
sending.add(deps.roomId);
const abort = new AbortController();
running = abort;
const { files, title } = state;
set({ phase: { kind: 'sending', done: 0 } });
let settled = 0;
try {
const res = await deps.run(files, title, () => {
settled += 1;
set({ phase: { kind: 'sending', done: settled } });
});
const res = await deps.run(
files,
title,
() => {
settled += 1;
set({ phase: { kind: 'sending', done: settled } });
},
abort.signal
);
deps.onSent?.();
const left = remainingAfter(files, res);
const phase: SharePhase = { kind: 'done', text: resultNote(res) };
@ -65,6 +73,7 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
} catch (e: any) {
set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } });
} finally {
running = undefined;
sending.delete(deps.roomId);
}
};
@ -80,6 +89,8 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }),
setTitle: (title: string) => set({ title }),
cancel: clear,
/** Stops a send in flight: the running upload is aborted and the rest are left picked. */
stop: () => running?.abort(),
send,
};
};

View file

@ -5,6 +5,8 @@ import {
DEFAULT_TITLE,
ShareFile,
SharePort,
CANCELLED,
freshest,
refusalOf,
remainingAfter,
resultNote,
@ -171,7 +173,11 @@ describe('adding to an existing album', () => {
return `$n${sent.length}`;
},
};
const res = await sendShareSet(port, { albumId: '$root', lastId: '$last' }, clips.slice(0, 2));
const res = await sendShareSet(
port,
{ albumId: '$root', lastId: '$last', lastTs: 1 },
clips.slice(0, 2)
);
expect(res.albumId).toBe('$root');
expect(res.sent).toBe(2);
expect(sent.map((c) => c.msgtype)).toEqual(['m.audio', 'm.audio']);
@ -214,3 +220,93 @@ describe('refusals', () => {
expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg');
});
});
describe('stopping a send', () => {
/** A port whose upload of `stopAt` aborts the signal as it runs, as the Stop button does mid-upload. */
const stopping = (stopAt: string, abortAfterUpload = false) => {
const ac = new AbortController();
const r = room();
const port: SharePort<ShareFile> = {
...r.port,
signal: ac.signal,
contentOf: async (f) => {
if (f.name !== stopAt) return r.port.contentOf(f);
ac.abort();
if (abortAfterUpload) return r.port.contentOf(f);
throw Object.assign(new Error('aborted'), { name: 'AbortError' });
},
};
return { port, r };
};
it('an upload aborted mid-file leaves that file and the rest unsent, named as stopped', async () => {
const { port, r } = stopping('b.ogg');
const res = await sendShareSet(port, 't', clips);
expect(res.sent).toBe(1);
expect(res.failed).toEqual(['b.ogg', 'c.txt']);
expect(res.failedAt).toEqual([1, 2]);
expect(res.reasons).toEqual([CANCELLED, CANCELLED]);
expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg']);
expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg', 'c.txt']);
expect(resultNote(res)).toBe('Shared 1. Not sent: b.ogg (stopped), c.txt (stopped)');
});
it('a file whose upload finished after the stop is not sent', async () => {
const { port, r } = stopping('b.ogg', true);
const res = await sendShareSet(port, 't', clips);
expect(res.sent).toBe(1);
expect(res.failedAt).toEqual([1, 2]);
expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg']);
});
it('a signal already aborted starts nothing and sends everything back as unsent', async () => {
const r = room();
const ac = new AbortController();
ac.abort();
let uploads = 0;
const res = await sendShareSet(
{
...r.port,
signal: ac.signal,
contentOf: async (f) => {
uploads += 1;
return r.port.contentOf(f);
},
},
{ albumId: '$a', lastId: '$l', lastTs: 1 },
clips
);
expect(uploads).toBe(0);
expect(res.sent).toBe(0);
expect(res.failedAt).toEqual([0, 1, 2]);
expect(r.events).toEqual([]);
});
it('an abort that is not a stop is an ordinary failure', async () => {
const r = room(['b.ogg']);
const res = await sendShareSet({ ...r.port, signal: new AbortController().signal }, 't', clips);
expect(res.failedAt).toEqual([1]);
expect(res.reasons).toEqual([undefined]);
});
});
describe('freshest', () => {
const album = { albumId: '$alb', lastId: '$old', lastTs: 100 };
it('moves lastId to a newer event of the same thread', () => {
expect(
freshest(album, [
{ id: '$mid', ts: 150, threadRoot: '$alb' },
{ id: '$new', ts: 200, threadRoot: '$alb' },
])
).toEqual({ albumId: '$alb', lastId: '$new', lastTs: 200 });
});
it('ignores older events and events of other threads or none', () => {
expect(
freshest(album, [
{ id: '$older', ts: 50, threadRoot: '$alb' },
{ id: '$other', ts: 900, threadRoot: '$else' },
{ id: '$loose', ts: 900 },
])
).toEqual(album);
});
});

View file

@ -13,6 +13,8 @@ export type SharePort<F extends ShareFile> = {
contentOf: (file: F) => Promise<Record<string, unknown>>;
/** Called after each file settles, for progress. */
onFile?: (index: number, result: 'sent' | 'failed') => void;
/** Aborted to stop the send: the running upload is the port's to cancel, the rest are not started. */
signal?: AbortSignal;
};
export type ShareResult = {
@ -26,8 +28,22 @@ export type ShareResult = {
reasons?: (string | undefined)[];
};
/** An album that already exists: new files join its thread after `lastId`, with no new root. */
export type ExistingAlbum = { albumId: string; lastId: string };
/** An album that already exists: new files join its thread after `lastId` (sent at `lastTs`), with no new root. */
export type ExistingAlbum = { albumId: string; lastId: string; lastTs: number };
/** An event as the room's live timeline holds it: its id, time, and the thread root it belongs to. */
export type ThreadEvent = { id: string; ts: number; threadRoot?: string };
/**
* `album` with `lastId` moved to the newest event the room's timeline holds in that thread, when it
* is newer than the one the album list knew. The list refreshes every 30 s; the timeline does not.
*/
export const freshest = (album: ExistingAlbum, events: ThreadEvent[]): ExistingAlbum =>
events
.filter((e) => e.threadRoot === album.albumId)
.reduce((a, e) => (e.ts > a.lastTs ? { ...a, lastId: e.id, lastTs: e.ts } : a), album);
export const CANCELLED = 'stopped';
const REFUSALS: Record<string, string> = {
M_TOO_LARGE: 'too large for this server',
@ -70,15 +86,35 @@ export const sendShareSet = async <F extends ShareFile>(
: into.albumId;
const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [], reasons: [] };
let lastId = typeof into === 'string' ? albumId : into.lastId;
// A stopped send leaves its file, and every file after it, unsent and picked, so one tap resumes.
const stopFrom = (from: number) => {
for (let j = from; j < files.length; j += 1) {
result.failed.push(files[j].name);
result.reasons?.push(CANCELLED);
result.failedAt.push(j);
}
};
for (let i = 0; i < files.length; i += 1) {
if (port.signal?.aborted) {
stopFrom(i);
break;
}
try {
// eslint-disable-next-line no-await-in-loop
const content = await port.contentOf(files[i]);
if (port.signal?.aborted) {
stopFrom(i);
break;
}
// eslint-disable-next-line no-await-in-loop
lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) });
result.sent += 1;
port.onFile?.(i, 'sent');
} catch (e) {
if (port.signal?.aborted) {
stopFrom(i);
break;
}
result.failed.push(files[i].name);
result.reasons?.push(refusalOf(e));
result.failedAt.push(i);

View file

@ -55,9 +55,13 @@ describe('foldVersions', () => {
]);
});
it('a malformed loop shows no head and does not hang', () => {
it('files in a loop with no head outside it are all shown, none hidden', () => {
const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$s', '$s')]);
expect(out.map((x) => x.id)).toEqual(['$s']);
expect(shape(out)).toEqual([
['$a', []],
['$b', []],
['$s', []],
]);
});
it('a loop reached from a head is walked once and ends', () => {

View file

@ -18,7 +18,7 @@ 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).
* replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (a real event id hashes its content and cannot cycle); files caught in a loop with no head outside it are all shown, none folded.
*/
export const foldVersions = <
F extends { id: string; sender: string; content: Record<string, unknown> }
@ -46,5 +46,11 @@ export const foldVersions = <
}
return out;
};
return files.filter((f) => !replaced.has(f.id)).map((h) => ({ ...h, earlier: chainOf(h) }));
const heads = files.filter((f) => !replaced.has(f.id));
const covered = new Set(heads.flatMap((h) => [h.id, ...chainOf(h).map((e) => e.id)]));
// Files that replace one another with no head outside the loop are never reached from a head; show
// each of them as it is rather than let the loop hide itself.
return files
.filter((f) => !replaced.has(f.id) || !covered.has(f.id))
.map((f) => ({ ...f, earlier: replaced.has(f.id) ? [] : chainOf(f) }));
};