From 1a0882c0834e7a48cf6040ab68add5b036ca054d Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 13:01:35 +0000 Subject: [PATCH] File zone share: failed files by position, send guard outside the component, say when the room turned encrypted Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 2 +- src/app/features/albums/ShareSet.tsx | 14 ++++++++++---- src/app/features/albums/shareSet.test.ts | 22 +++++++++++++++++++++- src/app/features/albums/shareSet.ts | 7 +++++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/file-zone.md b/docs/file-zone.md index af4f82a3..7f380d69 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -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. diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index 3eb139da..1d9c4302 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -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(); + 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(null); - const busy = useRef(false); const [files, setFiles] = useState([]); const [title, setTitle] = useState(''); const [phase, setPhase] = useState({ 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); } }; diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts index 3ac5f095..f43e5a35 100644 --- a/src/app/features/albums/shareSet.test.ts +++ b/src/app/features/albums/shareSet.test.ts @@ -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 = { + ...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); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts index 5d654f0c..803fbb87 100644 --- a/src/app/features/albums/shareSet.ts +++ b/src/app/features/albums/shareSet.ts @@ -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 ( files: F[] ): Promise => { 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 ( 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 ( * leaves its root as a lone message the album list does not show (it lists threads with replies). */ export const remainingAfter = (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)