Files desk cut 4: add files to an open album, named upload refusals #150
10 changed files with 170 additions and 33 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).
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue