File zone share: component tests, state machine moved out of ShareSet #116

Merged
void merged 3 commits from void/share-set-tests into dev 2026-11-12 13:15:50 +00:00
6 changed files with 553 additions and 86 deletions

View file

@ -114,6 +114,7 @@ Matrix options checked: (a) a thread, chosen: stock, rendered by every client, a
Client, `src/app/features/albums`:
- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`).
- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests.
- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only).
- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`.

View file

@ -0,0 +1,108 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { ClientConfigProvider } from '../../hooks/useClientConfig';
import type { ClientConfig } from '../../hooks/useClientConfig';
import { ShareState } from './shareController';
import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet';
const text = (html: string) =>
html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const noop = () => undefined;
const file = (name: string) => ({ name, type: 'audio/ogg' } as File);
const view = (over: Partial<ShareState<File>> = {}) =>
renderToStaticMarkup(
<ShareSetView
state={{ files: [], title: '', phase: { kind: 'idle' }, epoch: 0, ...over }}
onPickClick={noop}
onPicked={noop}
onTitle={noop}
onSend={noop}
onCancel={noop}
/>
);
describe('the share panel, drawn', () => {
it('idle: one "Share files" row and no title, names, Send or Cancel', () => {
const html = view();
expect(text(html)).toBe('Share files ›');
expect(html).not.toContain('aria-label="Album title"');
expect(html).toContain('type="file"');
expect(html).toContain('multiple');
expect(html).toContain('hidden');
});
it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => {
const html = view({ files: [file('a.ogg'), file('b.ogg')] });
expect(html).toContain('aria-label="Album title"');
expect(html).toContain('placeholder="Shared files"');
expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel');
expect(text(html)).not.toContain('Share files');
});
it('one picked file reads "Send 1 file"', () => {
expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel');
});
it('sending: progress replaces Send and Cancel, and the title is locked', () => {
const html = view({
files: [file('a'), file('b'), file('c')],
phase: { kind: 'sending', done: 2 },
});
expect(text(html)).toContain('Sending 2 of 3…');
expect(text(html)).not.toContain('Send 3 files');
expect(text(html)).not.toContain('Cancel');
expect(html).toMatch(
/aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/
);
});
it('the encrypted note shows above the kept pick', () => {
const html = view({
files: [file('a.ogg')],
phase: { kind: 'done', text: 'Not shared: this room is encrypted.' },
});
expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel');
});
it('after a full success the idle row and the note show together', () => {
expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe(
'Share files › Shared 2 files.'
);
});
});
describe('the flag', () => {
it('off, missing or empty offers no share', () => {
expect(shareEnabled({}, false)).toBe(false);
expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false);
expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false);
});
it('on offers it, except in an encrypted room', () => {
expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true);
expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false);
});
it('the hook reads the client config and the room, as AlbumView mounts it', () => {
const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never);
function Probe({ encrypted }: { encrypted: boolean }) {
return <span>{String(useShareEnabled(room(encrypted)))}</span>;
}
const probe = (config: ClientConfig, encrypted: boolean) =>
text(
renderToStaticMarkup(
<ClientConfigProvider value={config}>
<Probe encrypted={encrypted} />
</ClientConfigProvider>
)
);
expect(probe({}, false)).toBe('false');
expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true');
expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false');
});
});

View file

@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useRef, useState, useSyncExternalStore } from 'react';
import { Text } from 'folds';
import { useQueryClient } from '@tanstack/react-query';
import { Room } from 'matrix-js-sdk';
@ -8,104 +8,55 @@ import { uploadContent } from '../../utils/matrix';
import { safeFile } from '../../utils/mimeTypes';
import { getMsgContent } from '../room/msgContent';
import { FILE_HASH_KEY, hashForSend } from './fileReceipt';
import { DEFAULT_TITLE, remainingAfter, resultNote, sendShareSet } from './shareSet';
import { ShareController, ShareState, createShareController } from './shareController';
import { DEFAULT_TITLE, sendShareSet } from './shareSet';
import * as css from './AlbumView.css';
/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */
export const shareEnabled = (
config: { fileZoneShare?: { enabled?: boolean } },
encrypted: boolean
) => !!config.fileZoneShare?.enabled && !encrypted;
export const useShareEnabled = (room: Room): boolean =>
!!useClientConfig().fileZoneShare?.enabled && !room.hasEncryptionStateEvent();
shareEnabled(useClientConfig(), room.hasEncryptionStateEvent());
/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */
const sending = new Set<string>();
type Phase = { kind: 'idle' } | { kind: 'sending'; done: number } | { kind: 'done'; text: string };
type ShareViewProps = {
state: ShareState<File>;
onPickClick: () => void;
onPicked: (files: File[]) => void;
onTitle: (title: string) => void;
onSend: () => void;
onCancel: () => void;
inputRef?: React.Ref<HTMLInputElement>;
};
/**
* One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked
* names, Send. Sending makes one album of them (shareSet.ts). Nothing here is visible until used.
* names, Send. Nothing but the row is visible until files are picked.
*/
export function ShareSet({ room }: { room: Room }) {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const input = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<File[]>([]);
const [title, setTitle] = useState('');
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
const reset = () => {
setFiles([]);
setTitle('');
if (input.current) input.current.value = '';
};
const send = async () => {
// One send at a time, and never into a room that has turned encrypted since the panel mounted.
if (sending.has(room.roomId)) return;
if (room.hasEncryptionStateEvent()) {
setPhase({ kind: 'done', text: 'Not shared: this room is encrypted.' });
return;
}
sending.add(room.roomId);
const items = files.map((file) => ({ name: file.name, mimeType: file.type, file }));
setPhase({ kind: 'sending', done: 0 });
let settled = 0;
try {
const res = await sendShareSet(
{
send: async (content) => (await 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(mx, file, { onSuccess: resolve, onError: reject });
});
const content = await getMsgContent(
mx,
{
file,
originalFile: f.file,
metadata: { markedAsSpoiler: false },
encInfo: undefined,
},
mxc
);
const sha256 = await hashForSend(f.file);
if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 };
return content;
},
onFile: () => {
settled += 1;
setPhase({ kind: 'sending', done: settled });
},
},
title,
items
);
queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] });
const left = remainingAfter(items, res).map((i) => i.file);
setFiles(left);
if (left.length === 0) reset();
setPhase({ kind: 'done', text: resultNote(res) });
} catch (e: any) {
setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` });
} finally {
sending.delete(room.roomId);
}
};
export function ShareSetView({
state,
onPickClick,
onPicked,
onTitle,
onSend,
onCancel,
inputRef,
}: ShareViewProps) {
const { files, title, phase, epoch } = state;
return (
<div className={css.SharePanel}>
<input
ref={input}
key={epoch}
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => {
setFiles(Array.from(e.target.files ?? []));
setPhase({ kind: 'idle' });
}}
onChange={(e) => onPicked(Array.from(e.target.files ?? []))}
/>
{files.length === 0 && phase.kind !== 'sending' && (
<button type="button" className={css.Row} onClick={() => input.current?.click()}>
<button type="button" className={css.Row} onClick={onPickClick}>
<Text size="B400">Share files ›</Text>
</button>
)}
@ -122,7 +73,7 @@ export function ShareSet({ room }: { room: Room }) {
placeholder={DEFAULT_TITLE}
aria-label="Album title"
disabled={phase.kind === 'sending'}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => onTitle(e.target.value)}
/>
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
{files.map((f) => f.name).join(' · ')}
@ -133,12 +84,12 @@ export function ShareSet({ room }: { room: Room }) {
</Text>
) : (
<>
<button type="button" className={css.Row} onClick={send}>
<button type="button" className={css.Row} onClick={onSend}>
<Text size="B400">
Send {files.length} {files.length === 1 ? 'file' : 'files'}
</Text>
</button>
<button type="button" className={css.Row} onClick={reset}>
<button type="button" className={css.Row} onClick={onCancel}>
<Text size="B400">Cancel</Text>
</button>
</>
@ -148,3 +99,62 @@ export function ShareSet({ room }: { room: Room }) {
</div>
);
}
export function ShareSet({ room }: { room: Room }) {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const input = useRef<HTMLInputElement>(null);
// Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a
// rerender with a new client or Room object must not build another. Live values go through `live`.
const live = useRef({ mx, room, queryClient });
live.current = { mx, room, queryClient };
const [controller]: [ShareController<File>, unknown] = useState(() =>
createShareController<File>({
roomId: room.roomId,
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
onSent: () =>
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
run: (files, title, onFile) =>
sendShareSet(
{
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 content = await getMsgContent(
live.current.mx,
{
file,
originalFile: f.file,
metadata: { markedAsSpoiler: false },
encInfo: undefined,
},
mxc
);
const sha256 = await hashForSend(f.file);
if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 };
return content;
},
onFile,
},
title,
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
),
})
);
const state = useSyncExternalStore(controller.subscribe, controller.getState);
return (
<ShareSetView
state={state}
inputRef={input}
onPickClick={() => input.current?.click()}
onPicked={controller.pick}
onTitle={controller.setTitle}
onSend={controller.send}
onCancel={controller.cancel}
/>
);
}

View file

@ -0,0 +1,261 @@
import { describe, expect, it } from 'vitest';
import { Picked, ShareDeps, createShareController } from './shareController';
import { ShareResult } from './shareSet';
const file = (name: string): Picked => ({ name, type: 'audio/ogg' });
const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] });
/** 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 }[] = [];
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 });
return new Promise<ShareResult>((resolve, reject) => {
release = resolve;
fail = reject;
});
};
return { calls, run, release: (r: ShareResult) => release(r), fail: (e: Error) => fail(e) };
};
const make = (over: Partial<ShareDeps<Picked>> = {}, roomId = '!zone:x') => {
const h = hand();
const sentTo: string[] = [];
const c = createShareController<Picked>({
roomId,
isEncrypted: () => false,
run: h.run,
onSent: () => sentTo.push(roomId),
...over,
});
return { c, h, sentTo };
};
describe('picking', () => {
it('starts idle with nothing picked, and a pick holds the files in order', () => {
const { c } = make();
expect(c.getState().files).toEqual([]);
expect(c.getState().phase).toEqual({ kind: 'idle' });
c.pick([file('a'), file('b')]);
expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']);
});
it('Cancel empties the pick and the title and bumps the input epoch', () => {
const { c } = make();
c.pick([file('a')]);
c.setTitle('Take 4');
const before = c.getState().epoch;
c.cancel();
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: before + 1 });
});
it('subscribers hear every change and stop hearing after unsubscribe', () => {
const { c } = make();
let heard = 0;
const off = c.subscribe(() => {
heard += 1;
});
c.pick([file('a')]);
off();
c.pick([]);
expect(heard).toBe(1);
});
});
describe('the Send guard', () => {
it('a double tap starts one send', async () => {
const { c, h } = make();
c.pick([file('a'), file('b')]);
const first = c.send();
const second = c.send();
expect(h.calls).toHaveLength(1);
h.release(ok(2));
await Promise.all([first, second]);
expect(h.calls).toHaveLength(1);
});
it('a re-mounted panel for the same room cannot start a second send while one is in flight', async () => {
const { c, h } = make();
c.pick([file('a')]);
const first = c.send();
// The page was left and returned to: a new controller, nothing picked in it but a new pick.
const again = make({}, '!zone:x');
again.c.pick([file('b')]);
await again.c.send();
expect(again.h.calls).toHaveLength(0);
h.release(ok(1));
await first;
// Once the first has settled the room is free again.
const next = again.c.send();
expect(again.h.calls).toHaveLength(1);
again.h.release(ok(1));
await next;
});
it('another room is not blocked by a send in flight', async () => {
const a = make({}, '!a:x');
const b = make({}, '!b:x');
a.c.pick([file('a')]);
b.c.pick([file('b')]);
const sendA = a.c.send();
const sendB = b.c.send();
expect(b.h.calls).toHaveLength(1);
a.h.release(ok(1));
b.h.release(ok(1));
await Promise.all([sendA, sendB]);
});
it('sends nothing when nothing is picked', async () => {
const { c, h } = make();
await c.send();
expect(h.calls).toHaveLength(0);
});
it('a send that throws frees the room and says so', async () => {
const { c, h } = make();
c.pick([file('a')]);
const p = c.send();
h.fail(new Error('M_FORBIDDEN'));
await p;
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Could not share: M_FORBIDDEN' });
expect(c.getState().files).toHaveLength(1);
const retry = c.send();
expect(h.calls).toHaveLength(2);
h.release(ok(1));
await retry;
});
});
describe('progress and results', () => {
it('counts files as they settle, passing the title through', async () => {
const { c, h } = make();
c.pick([file('a'), file('b')]);
c.setTitle('Take 4');
const p = c.send();
expect(c.getState().phase).toEqual({ kind: 'sending', done: 0 });
expect(h.calls[0].title).toBe('Take 4');
h.calls[0].onFile();
expect(c.getState().phase).toEqual({ kind: 'sending', done: 1 });
h.release(ok(2));
await p;
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2 files.' });
});
it('full success clears the pick, tells the room list, and remounts the input', async () => {
const { c, h, sentTo } = make();
c.pick([file('a')]);
const { epoch } = c.getState();
const p = c.send();
h.release(ok(1));
await p;
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: epoch + 1 });
expect(sentTo).toEqual(['!zone:x']);
});
it('failed files stay picked by position, so a same-named twin that went is not re-sent', async () => {
const { c, h } = make();
const twins = [file('a.ogg'), file('a.ogg'), file('b.ogg')];
c.pick(twins);
const p = c.send();
h.release({ albumId: '$r', sent: 2, failed: ['a.ogg'], failedAt: [1] });
await p;
expect(c.getState().files).toEqual([twins[1]]);
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2. Not sent: a.ogg' });
});
it('when every upload failed everything stays picked and the note says nothing was shared', async () => {
const { c, h } = make();
const picked = [file('a'), file('b')];
c.pick(picked);
const p = c.send();
h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] });
await p;
expect(c.getState().files).toEqual(picked);
expect(c.getState().phase).toEqual({
kind: 'done',
text: 'Nothing was shared. Not sent: a, b',
});
});
it('a new pick after a result drops the result note', async () => {
const { c, h } = make();
c.pick([file('a')]);
const p = c.send();
h.release(ok(1));
await p;
c.pick([file('b')]);
expect(c.getState().phase).toEqual({ kind: 'idle' });
});
});
describe('what a send leaves behind', () => {
it('keeps the title when some files failed, so the retry has the same name', async () => {
const { c, h } = make();
c.pick([file('a'), file('b')]);
c.setTitle('Take 4');
const p = c.send();
h.release({ albumId: '$r', sent: 1, failed: ['b'], failedAt: [1] });
await p;
expect(c.getState().title).toBe('Take 4');
});
it('tells the room list only after the send settled, and not when it threw', async () => {
const { c, h, sentTo } = make();
c.pick([file('a')]);
const p = c.send();
expect(sentTo).toEqual([]);
h.release(ok(1));
await p;
expect(sentTo).toEqual(['!zone:x']);
const bad = make();
bad.c.pick([file('a')]);
const q = bad.c.send();
bad.h.fail(new Error('boom'));
await q;
expect(bad.sentTo).toEqual([]);
});
it('a success lands in one notification: the pick is never empty while still sending', async () => {
const { c, h } = make();
c.pick([file('a')]);
const p = c.send();
const seen: string[] = [];
c.subscribe(() => {
const st = c.getState();
seen.push(`${st.files.length}:${st.phase.kind}`);
});
h.release(ok(1));
await p;
expect(seen).toEqual(['0:done']);
});
});
describe('an encrypted room', () => {
it('says so, sends nothing and keeps the pick', async () => {
const { c, h } = make({ isEncrypted: () => true });
c.pick([file('a')]);
await c.send();
expect(c.getState().phase).toEqual({
kind: 'done',
text: 'Not shared: this room is encrypted.',
});
expect(c.getState().files).toHaveLength(1);
expect(h.calls).toHaveLength(0);
});
it('is checked at Send, not at mount: a room that turns encrypted after the pick is refused', async () => {
let encrypted = false;
const { c, h } = make({ isEncrypted: () => encrypted });
c.pick([file('a')]);
encrypted = true;
await c.send();
expect(h.calls).toHaveLength(0);
expect(c.getState().phase).toMatchObject({
kind: 'done',
text: 'Not shared: this room is encrypted.',
});
});
});

View file

@ -0,0 +1,87 @@
// The share panel's state machine, apart from React and Matrix: what is picked, what phase the
// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it.
import { ShareResult, remainingAfter, resultNote } from './shareSet';
export type Picked = { name: string; type: string };
export type SharePhase =
| { kind: 'idle' }
| { kind: 'sending'; done: number }
| { kind: 'done'; text: string };
export type ShareState<F extends Picked> = {
files: F[];
title: string;
phase: SharePhase;
/** Bumps whenever the pick is emptied, so the file input can be remounted and re-pick the same files. */
epoch: number;
};
export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.';
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>;
/** Called after a send that reached the room, whatever it left unsent. */
onSent?: () => void;
};
/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */
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>();
const set = (patch: Partial<ShareState<F>>) => {
state = { ...state, ...patch };
listeners.forEach((l) => l());
};
const clear = () => set({ files: [], title: '', epoch: state.epoch + 1 });
const send = async () => {
if (sending.has(deps.roomId) || state.files.length === 0) return;
if (deps.isEncrypted()) {
set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } });
return;
}
sending.add(deps.roomId);
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 } });
});
deps.onSent?.();
const left = remainingAfter(files, res);
const phase: SharePhase = { kind: 'done', text: resultNote(res) };
// One notification: no state is ever drawn with the pick emptied and the send still running.
if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase });
else set({ files: left, phase });
} catch (e: any) {
set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } });
} finally {
sending.delete(deps.roomId);
}
};
return {
getState: () => state,
subscribe: (l: () => void) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }),
setTitle: (title: string) => set({ title }),
cancel: clear,
send,
};
};
export type ShareController<F extends Picked> = ReturnType<typeof createShareController<F>>;

View file

@ -72,7 +72,7 @@ export const sendShareSet = async <F extends ShareFile>(
* makes a second album (adding to an existing one is not built), and a share where nothing went
* leaves its root as a lone message the album list does not show (it lists threads with replies).
*/
export const remainingAfter = <F extends ShareFile>(files: F[], result: ShareResult): F[] =>
export const remainingAfter = <T>(files: T[], result: ShareResult): T[] =>
files.filter((_, i) => result.failedAt.includes(i));
export const resultNote = (result: ShareResult): string => {