Files desk: refuse files over the server's upload cap before sending #157

Merged
void merged 2 commits from void/files-desk-cut5 into dev 2026-11-14 01:47:42 +00:00
5 changed files with 180 additions and 12 deletions

View file

@ -118,7 +118,7 @@ Under A the paused service is not built, and most of its list disappears:
## Cuts under A
Status 2026-11-13 (dev a078346e): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151). **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room.
Status 2026-11-14 (dev da822e9e plus the size-refusal cut): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151), and the pre-send size refusal: a file larger than the server's `m.upload.size` is named and left unsent before any upload or album root, and a server stating no cap refuses nothing. **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room.
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).

View file

@ -3,18 +3,24 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { MediaConfig, MediaConfigProvider } from '../../hooks/useMediaConfig';
// ShareSet hands its folder to the title the send lands under. The controller is held still and its
// `run` captured, and sendShareSet records the title it was given, so the path from the `folder`
// prop to the album root's title is read without a homeserver or a DOM.
const captured = vi.hoisted(() => ({
run: undefined as undefined | ((files: unknown[], title: string, onFile: () => void) => unknown),
maxBytes: undefined as undefined | (() => number | undefined),
titles: [] as string[],
}));
vi.mock('./shareController', () => ({
createShareController: (deps: { run: NonNullable<typeof captured.run> }) => {
createShareController: (deps: {
run: NonNullable<typeof captured.run>;
maxBytes: NonNullable<typeof captured.maxBytes>;
}) => {
captured.run = deps.run;
captured.maxBytes = deps.maxBytes;
return {
subscribe: () => () => undefined,
getState: () => ({ files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }),
@ -49,7 +55,11 @@ const ev = (id: string, ts: number, threadRootId?: string, status: string | null
threadRootId,
status,
});
const mount = (folder?: string[], into?: { albumId: string; lastId: string; lastTs: number }) => {
const mount = (
folder?: string[],
into?: { albumId: string; lastId: string; lastTs: number },
mediaConfig: MediaConfig = {}
) => {
const room = {
roomId: '!r:x',
hasEncryptionStateEvent: () => false,
@ -58,7 +68,9 @@ const mount = (folder?: string[], into?: { albumId: string; lastId: string; last
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<MatrixClientProvider value={{ sendMessage: async () => ({ event_id: '$e' }) } as never}>
<ShareSet room={room} folder={folder} into={into} />
<MediaConfigProvider value={mediaConfig}>
<ShareSet room={room} folder={folder} into={into} />
</MediaConfigProvider>
</MatrixClientProvider>
</QueryClientProvider>
);
@ -112,3 +124,17 @@ describe('ShareSet: the folder reaches the album title', () => {
expect(await send('x')).toBe('into:$alb@$sentNotEchoed');
});
});
describe('ShareSet: the homeserver upload cap reaches the controller', () => {
it('is the server-stated m.upload.size, and none when the server states none', () => {
mount([], undefined, { 'm.upload.size': 52428800 });
expect(captured.maxBytes?.()).toBe(52428800);
mount([], undefined, {});
expect(captured.maxBytes?.()).toBeUndefined();
// A cap that is not a positive number is no cap, never "refuse everything".
[0, -1, Number.NaN, '50' as unknown as number].forEach((bad) => {
mount([], undefined, { 'm.upload.size': bad });
expect(captured.maxBytes?.()).toBeUndefined();
});
});
});

View file

@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { EventStatus, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { uploadContent } from '../../utils/matrix';
import { safeFile } from '../../utils/mimeTypes';
import { getMsgContent } from '../room/msgContent';
@ -131,15 +132,20 @@ type ShareSetProps = {
export function ShareSet({ room, folder = [], into }: ShareSetProps) {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const mediaConfig = useMediaConfig();
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, folder, into });
live.current = { mx, room, queryClient, folder, into };
const live = useRef({ mx, room, queryClient, folder, into, mediaConfig });
live.current = { mx, room, queryClient, folder, into, mediaConfig };
const [controller]: [ShareController<File>, unknown] = useState(() =>
createShareController<File>({
roomId: room.roomId,
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
maxBytes: () => {
const cap = live.current.mediaConfig['m.upload.size'];
return typeof cap === 'number' && Number.isFinite(cap) && cap > 0 ? cap : undefined;
},
onSent: () =>
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
run: (files, title, onFile, signal) =>

View file

@ -2,7 +2,8 @@ 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 file = (name: string, size?: number): Picked => ({ name, type: 'audio/ogg', size });
const MB = 1024 * 1024;
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. */
@ -279,3 +280,101 @@ describe('Stop', () => {
await again;
});
});
describe('files over the homeserver cap', () => {
const cap = 50 * MB;
const capped = () => make({ maxBytes: () => cap });
it('are named in the note and never sent; the rest go', async () => {
const { c, h } = capped();
c.pick([file('small.ogg', 3 * MB), file('film.mp4', 80 * MB), file('also.ogg', 1)]);
const sent = c.send();
expect(h.calls[0].files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']);
expect(c.getState().files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']);
h.release(ok(2));
await sent;
expect(c.getState().phase).toEqual({
kind: 'done',
text: 'Shared 2 files. Too large for this server (limit 50 MB): film.mp4',
});
expect(c.getState().files).toEqual([]);
});
it('are not kept for a retry that cannot help them, while a failed small file is', async () => {
const { c, h } = capped();
c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]);
const sent = c.send();
h.release({ albumId: '$r', sent: 0, failed: ['a.ogg'], failedAt: [0] });
await sent;
expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']);
});
it('a pick that is all over the cap sends nothing: no run, so no album root, and the pick clears', async () => {
const { c, h, sentTo } = capped();
c.pick([file('a.mp4', 51 * MB), file('b.mp4', 99 * MB)]);
const { epoch } = c.getState();
await c.send();
expect(h.calls).toHaveLength(0);
expect(sentTo).toEqual([]);
expect(c.getState()).toMatchObject({ files: [], epoch: epoch + 1 });
expect(c.getState().phase).toEqual({
kind: 'done',
text: 'Nothing was shared. Too large for this server (limit 50 MB): a.mp4, b.mp4',
});
});
it('a file of exactly the cap goes: the server refuses only what is larger', async () => {
const { c, h } = capped();
c.pick([file('edge.bin', cap)]);
const sent = c.send();
expect(h.calls[0].files).toHaveLength(1);
h.release(ok(1));
await sent;
});
it('the room is not held busy by an all-refused pick, so the next pick sends', async () => {
const { c, h } = capped();
c.pick([file('a.mp4', 99 * MB)]);
await c.send();
c.pick([file('b.ogg', 1)]);
const sent = c.send();
expect(h.calls).toHaveLength(1);
h.release(ok(1));
await sent;
});
it('a server that states no cap, or a file with no size, refuses nothing', async () => {
const none = make();
none.c.pick([file('a', 10 ** 12)]);
const s1 = none.c.send();
expect(none.h.calls[0].files).toHaveLength(1);
none.h.release(ok(1));
await s1;
const unsized = make({ maxBytes: () => cap }, '!other:x');
unsized.c.pick([file('b')]);
const s2 = unsized.c.send();
expect(unsized.h.calls[0].files).toHaveLength(1);
unsized.h.release(ok(1));
await s2;
});
it('a fractional cap reads with one decimal', async () => {
const { c } = make({ maxBytes: () => 1.5 * MB });
c.pick([file('a', 2 * MB)]);
await c.send();
expect(c.getState().phase).toMatchObject({ text: expect.stringContaining('limit 1.5 MB') });
});
it('a run that throws still names the refused files, which are not kept', async () => {
const { c, h } = capped();
c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]);
const sent = c.send();
h.fail(new Error('network down'));
await sent;
expect(c.getState().phase).toEqual({
kind: 'done',
text: 'Could not share: network down. Too large for this server (limit 50 MB): big.wav',
});
expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']);
});
});

View file

@ -2,7 +2,7 @@
// 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 Picked = { name: string; type: string; size?: number };
export type SharePhase =
| { kind: 'idle' }
@ -19,11 +19,28 @@ export type ShareState<F extends Picked> = {
export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.';
const MIB = 1024 * 1024;
/**
* Files the homeserver would refuse for size (its `m.upload.size`, which Synapse enforces as a
* limit, so a file of exactly that size goes), split from the rest before anything is sent. A
* missing cap or size refuses nothing.
*/
export const splitBySize = <F extends Picked>(files: F[], cap: number | undefined) => {
const over = (f: F) => cap !== undefined && f.size !== undefined && f.size > cap;
return { sendable: files.filter((f) => !over(f)), tooLarge: files.filter(over) };
};
const tooLargeNote = (names: string[], cap: number): string =>
`Too large for this server (limit ${Math.round((cap / MIB) * 10) / 10} MB): ${names.join(', ')}`;
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, signal: AbortSignal) => Promise<ShareResult>;
/** The homeserver's upload cap in bytes (`m.upload.size`), when it states one. */
maxBytes?: () => number | undefined;
/** Called after a send that reached the room, whatever it left unsent. */
onSent?: () => void;
};
@ -48,11 +65,27 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } });
return;
}
// Files over the cap are named and dropped from the pick before any upload or album root: a
// retry cannot help them, and a root with nothing under it is a lone message nobody lists.
const cap = deps.maxBytes?.();
const { sendable, tooLarge } = splitBySize(state.files, cap);
const sizeNote = tooLarge.length
? tooLargeNote(
tooLarge.map((f) => f.name),
cap as number
)
: '';
if (sendable.length === 0) {
const phase: SharePhase = { kind: 'done', text: `Nothing was shared. ${sizeNote}` };
set({ files: [], title: '', epoch: state.epoch + 1, phase });
return;
}
sending.add(deps.roomId);
const abort = new AbortController();
running = abort;
const { files, title } = state;
set({ phase: { kind: 'sending', done: 0 } });
const { title } = state;
const files = sendable;
set({ files, phase: { kind: 'sending', done: 0 } });
let settled = 0;
try {
const res = await deps.run(
@ -66,12 +99,16 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
);
deps.onSent?.();
const left = remainingAfter(files, res);
const phase: SharePhase = { kind: 'done', text: resultNote(res) };
const phase: SharePhase = {
kind: 'done',
text: sizeNote ? `${resultNote(res)} ${sizeNote}` : 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'}` } });
const failure = `Could not share: ${e?.message ?? 'send failed'}`;
set({ phase: { kind: 'done', text: sizeNote ? `${failure}. ${sizeNote}` : failure } });
} finally {
running = undefined;
sending.delete(deps.roomId);