Files desk: Stop a send in flight, show replaces loops, freshest last event on add #151
14 changed files with 274 additions and 42 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) }));
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue