File zone share: failed files by position, send guard outside the component, say when the room turned encrypted
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m33s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-12 13:01:35 +00:00
commit 1a0882c083
4 changed files with 37 additions and 8 deletions

View file

@ -114,7 +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`).
- `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, 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).
- `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`.
Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts.

View file

@ -15,6 +15,9 @@ import * as css from './AlbumView.css';
export const useShareEnabled = (room: Room): boolean =>
!!useClientConfig().fileZoneShare?.enabled && !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 };
/**
@ -25,7 +28,6 @@ export function ShareSet({ room }: { room: Room }) {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const input = useRef<HTMLInputElement>(null);
const busy = useRef(false);
const [files, setFiles] = useState<File[]>([]);
const [title, setTitle] = useState('');
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
@ -38,8 +40,12 @@ export function ShareSet({ room }: { room: Room }) {
const send = async () => {
// One send at a time, and never into a room that has turned encrypted since the panel mounted.
if (busy.current || room.hasEncryptionStateEvent()) return;
busy.current = true;
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;
@ -82,7 +88,7 @@ export function ShareSet({ room }: { room: Room }) {
} catch (e: any) {
setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` });
} finally {
busy.current = false;
sending.delete(room.roomId);
}
};

View file

@ -56,7 +56,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: [] });
expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] });
const album = r.album();
expect(album.title).toBe('Take 4');
expect(album.files.map((f) => [f.name, f.kind])).toEqual([
@ -117,6 +117,26 @@ describe('the share flag', () => {
});
describe('after a send', () => {
it('keeps the failed one of two same-named files, not both', async () => {
const twins: ShareFile[] = [
{ name: 'a.ogg', mimeType: 'audio/ogg' },
{ name: 'a.ogg', mimeType: 'audio/ogg' },
];
let n = 0;
const r = room();
const port: SharePort<ShareFile> = {
...r.port,
contentOf: async (f) => {
n += 1;
if (n === 2) throw new Error('second upload failed');
return r.port.contentOf(f);
},
};
const res = await sendShareSet(port, 't', twins);
expect(res.failedAt).toEqual([1]);
expect(remainingAfter(twins, res)).toEqual([twins[1]]);
});
it('keeps only the failed files picked, so a retry is one tap', async () => {
const r = room(['b.ogg']);
const res = await sendShareSet(r.port, 't', clips);

View file

@ -20,6 +20,8 @@ export type ShareResult = {
albumId: string;
sent: number;
failed: string[];
/** Positions in the files array of those that failed; names can repeat, positions cannot. */
failedAt: number[];
};
/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */
@ -46,7 +48,7 @@ export const sendShareSet = async <F extends ShareFile>(
files: F[]
): Promise<ShareResult> => {
const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) });
const result: ShareResult = { albumId, sent: 0, failed: [] };
const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] };
let lastId = albumId;
for (let i = 0; i < files.length; i += 1) {
try {
@ -58,6 +60,7 @@ export const sendShareSet = async <F extends ShareFile>(
port.onFile?.(i, 'sent');
} catch {
result.failed.push(files[i].name);
result.failedAt.push(i);
port.onFile?.(i, 'failed');
}
}
@ -70,7 +73,7 @@ export const sendShareSet = async <F extends ShareFile>(
* 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[] =>
files.filter((f) => result.failed.includes(f.name));
files.filter((_, i) => result.failedAt.includes(i));
export const resultNote = (result: ShareResult): string => {
if (result.failed.length === 0)