diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 88f1cb39..fa7242d2 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -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). diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx index f4ec40f5..abb8f2bb 100644 --- a/src/app/features/albums/ShareSet.folder.test.tsx +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -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 }) => { + createShareController: (deps: { + run: NonNullable; + maxBytes: NonNullable; + }) => { 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( ({ event_id: '$e' }) } as never}> - + + + ); @@ -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(); + }); + }); +}); diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index a7f2f783..b7f26e7e 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -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(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, unknown] = useState(() => createShareController({ 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) => diff --git a/src/app/features/albums/shareController.test.ts b/src/app/features/albums/shareController.test.ts index 98c31a60..b163b736 100644 --- a/src/app/features/albums/shareController.test.ts +++ b/src/app/features/albums/shareController.test.ts @@ -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']); + }); +}); diff --git a/src/app/features/albums/shareController.ts b/src/app/features/albums/shareController.ts index c7b5b521..72f786c9 100644 --- a/src/app/features/albums/shareController.ts +++ b/src/app/features/albums/shareController.ts @@ -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 = { 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 = (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 = { 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; + /** 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 = (deps: ShareDeps) => { 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 = (deps: ShareDeps) => { ); 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);