Files desk cut 4: add files to an open album, named upload refusals #150

Merged
void merged 2 commits from void/files-desk-cut4 into dev 2026-11-13 23:11:36 +00:00
10 changed files with 170 additions and 33 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).
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`.
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; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files.
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`).

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 } from './buildAlbums';
import { Album, AlbumFile, lastEventId } from './buildAlbums';
import { folderOf, listFolder, pathLabel } from './albumFolders';
import { FileReadBy } from './FileReadBy';
import { ShareSet, useShareEnabled } from './ShareSet';
@ -142,6 +142,13 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
</Text>
{open.count === 0 && <Text>This album has no files.</Text>}
{canShare && (
<ShareSet
key={open.id}
room={room}
into={{ albumId: open.id, lastId: lastEventId(open) }}
/>
)}
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
{open.files.map((f) => (
<VersionedFileRow key={f.id} room={room} file={f} author={authorOf(open)} />

View file

@ -27,8 +27,8 @@ vi.mock('./shareController', () => ({
}));
vi.mock('./shareSet', async (importOriginal) => ({
...(await importOriginal<typeof import('./shareSet')>()),
sendShareSet: async (_port: unknown, title: string) => {
captured.titles.push(title);
sendShareSet: async (_port: unknown, into: string | { albumId: string }) => {
captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}`);
return { albumId: '$a', sent: 0, failed: [], failedAt: [] };
},
}));
@ -42,12 +42,12 @@ vi.mock('react', async (importOriginal) => {
// eslint-disable-next-line import/first
import { ShareSet } from './ShareSet';
const mount = (folder?: string[]) => {
const mount = (folder?: string[], into?: { albumId: string; lastId: string }) => {
const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never;
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<MatrixClientProvider value={{ sendMessage: async () => ({ event_id: '$e' }) } as never}>
<ShareSet room={room} folder={folder} />
<ShareSet room={room} folder={folder} into={into} />
</MatrixClientProvider>
</QueryClientProvider>
);
@ -82,4 +82,9 @@ describe('ShareSet: the folder reaches the album title', () => {
mount(['a']);
expect(await send('b/c')).toBe('a/b/c');
});
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');
});
});

View file

@ -14,7 +14,7 @@ const text = (html: string) =>
const noop = () => undefined;
const file = (name: string) => ({ name, type: 'audio/ogg' } as File);
const view = (over: Partial<ShareState<File>> = {}) =>
const view = (over: Partial<ShareState<File>> = {}, adding = false) =>
renderToStaticMarkup(
<ShareSetView
state={{ files: [], title: '', phase: { kind: 'idle' }, epoch: 0, ...over }}
@ -23,6 +23,7 @@ const view = (over: Partial<ShareState<File>> = {}) =>
onTitle={noop}
onSend={noop}
onCancel={noop}
adding={adding}
/>
);
@ -76,6 +77,18 @@ describe('the share panel, drawn', () => {
});
});
describe('adding to an existing album', () => {
it('the idle row says Add files', () => {
expect(text(view({}, true))).toBe('Add files ›');
});
it('picked: no title field, the names, Send and Cancel', () => {
const html = view({ files: [file('a.ogg')] }, true);
expect(html).not.toContain('aria-label="Album title"');
expect(text(html)).toBe('a.ogg Send 1 file Cancel');
});
});
describe('the flag', () => {
it('off, missing or empty offers no share', () => {
expect(shareEnabled({}, false)).toBe(false);

View file

@ -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, sendShareSet } from './shareSet';
import { DEFAULT_TITLE, ExistingAlbum, sendShareSet } 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,8 @@ type ShareViewProps = {
onTitle: (title: string) => void;
onSend: () => void;
onCancel: () => void;
/** Adding to an existing album: the files join it, so there is no title to ask for. */
adding?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
};
@ -43,6 +45,7 @@ export function ShareSetView({
onTitle,
onSend,
onCancel,
adding,
inputRef,
}: ShareViewProps) {
const { files, title, phase, epoch } = state;
@ -58,7 +61,7 @@ export function ShareSetView({
/>
{files.length === 0 && phase.kind !== 'sending' && (
<button type="button" className={css.Row} onClick={onPickClick}>
<Text size="B400">Share files ›</Text>
<Text size="B400">{adding ? 'Add files ›' : 'Share files ›'}</Text>
</button>
)}
{phase.kind === 'done' && (
@ -68,14 +71,16 @@ export function ShareSetView({
)}
{files.length > 0 && (
<>
<input
className={css.ShareTitle}
value={title}
placeholder={DEFAULT_TITLE}
aria-label="Album title"
disabled={phase.kind === 'sending'}
onChange={(e) => onTitle(e.target.value)}
/>
{!adding && (
<input
className={css.ShareTitle}
value={title}
placeholder={DEFAULT_TITLE}
aria-label="Album title"
disabled={phase.kind === 'sending'}
onChange={(e) => onTitle(e.target.value)}
/>
)}
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
{files.map((f) => f.name).join(' · ')}
</Text>
@ -101,15 +106,21 @@ export function ShareSetView({
);
}
/** `folder` is the path the share lands in: its albums are titled under it (docs/commons-files-desk.md). */
export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] }) {
type ShareSetProps = {
room: Room;
/** The path a new album lands in: its title is prefixed by it (docs/commons-files-desk.md). */
folder?: string[];
/** Add the files to this album instead of making one. */
into?: ExistingAlbum;
};
export function ShareSet({ room, folder = [], into }: ShareSetProps) {
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, folder });
live.current = { mx, room, queryClient, folder };
const live = useRef({ mx, room, queryClient, folder, into });
live.current = { mx, room, queryClient, folder, into };
const [controller]: [ShareController<File>, unknown] = useState(() =>
createShareController<File>({
roomId: room.roomId,
@ -142,7 +153,7 @@ export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[]
},
onFile,
},
titleIn(live.current.folder, title, DEFAULT_TITLE),
live.current.into ?? titleIn(live.current.folder, title, DEFAULT_TITLE),
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
),
})
@ -157,6 +168,7 @@ export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[]
onTitle={controller.setTitle}
onSend={controller.send}
onCancel={controller.cancel}
adding={!!into}
/>
);
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
import { AlbumInput, ZoneEvent, buildAlbums, lastEventId } from './buildAlbums';
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
event_id: id,
@ -239,6 +239,17 @@ describe('versions: cover and sender', () => {
});
});
describe('lastEventId', () => {
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');
});
});
describe('Mind receipts', () => {
const HASH = 'a'.repeat(64);
const file = (id: string, sha?: string) =>

View file

@ -134,3 +134,10 @@ export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => {
})
.sort((a, b) => b.ts - a.ts);
};
/** 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 =>
album.files
.flatMap((f) => [f, ...f.earlier])
.reduce<AlbumFile | undefined>((a, f) => (!a || f.ts >= a.ts ? f : a), undefined)?.id ??
album.id;

View file

@ -5,6 +5,7 @@ import {
DEFAULT_TITLE,
ShareFile,
SharePort,
refusalOf,
remainingAfter,
resultNote,
sendShareSet,
@ -56,7 +57,7 @@ describe('sendShareSet', () => {
it('reads back as one album of the files in pick order', async () => {
const r = room();
const res = await sendShareSet(r.port, 'Take 4', clips);
expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] });
expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [], reasons: [] });
const album = r.album();
expect(album.title).toBe('Take 4');
expect(album.files.map((f) => [f.name, f.kind])).toEqual([
@ -158,3 +159,58 @@ describe('after a send', () => {
expect(resultNote(res)).toBe('Shared 3 files.');
});
});
describe('adding to an existing album', () => {
it('sends no root; each file threads under the album, the first after `lastId`', async () => {
const r = room();
const sent: Record<string, unknown>[] = [];
const port = {
...r.port,
send: async (c: Record<string, unknown>) => {
sent.push(c);
return `$n${sent.length}`;
},
};
const res = await sendShareSet(port, { albumId: '$root', lastId: '$last' }, 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']);
expect(sent.map((c) => c['m.relates_to'])).toEqual([
threadRelation('$root', '$last'),
threadRelation('$root', '$n1'),
]);
});
});
describe('refusals', () => {
const refuse = (errcode: string): SharePort<ShareFile> => ({
send: async () => '$e',
contentOf: async () => {
throw Object.assign(new Error('raw server text'), { errcode });
},
});
it('names the three upload refusals and nothing else', () => {
expect(refusalOf({ errcode: 'M_TOO_LARGE' })).toBe('too large for this server');
expect(refusalOf({ errcode: 'M_FORBIDDEN' })).toBe('not allowed to upload here');
expect(refusalOf({ errcode: 'M_LIMIT_EXCEEDED' })).toBe(
'uploading too fast, try again shortly'
);
expect(refusalOf({ errcode: 'M_UNKNOWN' })).toBeUndefined();
expect(refusalOf({ errcode: 'constructor' })).toBeUndefined();
expect(refusalOf(new Error('x'))).toBeUndefined();
expect(refusalOf(null)).toBeUndefined();
});
it('the note carries the reason beside each refused file', async () => {
const res = await sendShareSet(refuse('M_TOO_LARGE'), 't', clips.slice(0, 2));
expect(resultNote(res)).toBe(
'Nothing was shared. Not sent: a.ogg (too large for this server), b.ogg (too large for this server)'
);
});
it('an unnamed failure lists the bare name', async () => {
const res = await sendShareSet(refuse('M_UNKNOWN'), 't', clips.slice(0, 1));
expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg');
});
});

View file

@ -22,6 +22,23 @@ export type ShareResult = {
failed: string[];
/** Positions in the files array of those that failed; names can repeat, positions cannot. */
failedAt: number[];
/** Why each failed file failed, parallel to `failed`, when the homeserver's refusal is one we name. */
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 };
const REFUSALS: Record<string, string> = {
M_TOO_LARGE: 'too large for this server',
M_FORBIDDEN: 'not allowed to upload here',
M_LIMIT_EXCEEDED: 'uploading too fast, try again shortly',
};
/** The sentence for a homeserver refusal we know, from the error's Matrix errcode. */
export const refusalOf = (e: unknown): string | undefined => {
const code = (e as { errcode?: unknown } | null)?.errcode;
return typeof code === 'string' && Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : undefined;
};
/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */
@ -44,12 +61,15 @@ export const titleOrDefault = (title: string): string => title.trim() || DEFAULT
*/
export const sendShareSet = async <F extends ShareFile>(
port: SharePort<F>,
title: string,
into: string | ExistingAlbum,
files: F[]
): Promise<ShareResult> => {
const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) });
const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] };
let lastId = albumId;
const albumId =
typeof into === 'string'
? await port.send({ msgtype: 'm.text', body: titleOrDefault(into) })
: into.albumId;
const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [], reasons: [] };
let lastId = typeof into === 'string' ? albumId : into.lastId;
for (let i = 0; i < files.length; i += 1) {
try {
// eslint-disable-next-line no-await-in-loop
@ -58,8 +78,9 @@ export const sendShareSet = async <F extends ShareFile>(
lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) });
result.sent += 1;
port.onFile?.(i, 'sent');
} catch {
} catch (e) {
result.failed.push(files[i].name);
result.reasons?.push(refusalOf(e));
result.failedAt.push(i);
port.onFile?.(i, 'failed');
}
@ -69,15 +90,20 @@ export const sendShareSet = async <F extends ShareFile>(
/**
* What stays picked after a send: only the files that did not go, so a retry is one tap. A retry
* makes a second album (adding to an existing one is not built), and a share where nothing went
* of a new album makes a second album, 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 = <T>(files: T[], result: ShareResult): T[] =>
files.filter((_, i) => result.failedAt.includes(i));
const failedList = (result: ShareResult): string =>
result.failed
.map((name, i) => (result.reasons?.[i] ? `${name} (${result.reasons[i]})` : name))
.join(', ');
export const resultNote = (result: ShareResult): string => {
if (result.failed.length === 0)
return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`;
if (result.sent === 0) return `Nothing was shared. Not sent: ${result.failed.join(', ')}`;
return `Shared ${result.sent}. Not sent: ${result.failed.join(', ')}`;
if (result.sent === 0) return `Nothing was shared. Not sent: ${failedList(result)}`;
return `Shared ${result.sent}. Not sent: ${failedList(result)}`;
};