From 8ba931186f676f3ac9f5600514c75ad583cacc55 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 12:51:40 +0000 Subject: [PATCH 1/3] File zone: share a set of files as one album, behind fileZoneShare.enabled (off) Co-Authored-By: Claude Sonnet 5.5 --- config.json | 15 +-- docs/file-zone.md | 38 +++++-- src/app/features/albums/AlbumView.css.ts | 16 +++ src/app/features/albums/AlbumView.tsx | 3 + src/app/features/albums/ShareSet.tsx | 136 +++++++++++++++++++++++ src/app/features/albums/shareSet.test.ts | 109 ++++++++++++++++++ src/app/features/albums/shareSet.ts | 65 +++++++++++ src/app/features/room/RoomInput.tsx | 18 +-- src/app/features/room/msgContent.ts | 13 +++ src/app/hooks/useClientConfig.ts | 3 + 10 files changed, 380 insertions(+), 36 deletions(-) create mode 100644 src/app/features/albums/ShareSet.tsx create mode 100644 src/app/features/albums/shareSet.test.ts create mode 100644 src/app/features/albums/shareSet.ts diff --git a/config.json b/config.json index ef2a7a94..93396d91 100644 --- a/config.json +++ b/config.json @@ -1,18 +1,12 @@ { "defaultHomeserver": 0, - "homeserverList": [ - "chat.gamecult.org" - ], + "homeserverList": ["chat.gamecult.org"], "allowCustomHomeservers": false, "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#gamecult:gamecult.org" - ], + "spaces": ["#gamecult:gamecult.org"], "rooms": [], - "servers": [ - "gamecult.org" - ] + "servers": ["gamecult.org"] }, "hashRouter": { "enabled": true, @@ -25,6 +19,9 @@ "dings": { "enabled": false }, + "fileZoneShare": { + "enabled": false + }, "registration": { "enabled": false, "url": "" diff --git a/docs/file-zone.md b/docs/file-zone.md index 01b5a4c5..a4e2f102 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -71,21 +71,21 @@ Reuse the composer's pipeline, pointed at an album instead of the main timeline: - `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. - On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). -- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. +- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. - Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. ## Existing code to reuse (verified present in the repo) -| need | where | -|---|---| -| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | -| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | -| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | -| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | -| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | -| event content builders | `features/room/msgContent.ts` | -| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | -| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | +| need | where | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | +| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | +| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | +| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | +| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | +| event content builders | `features/room/msgContent.ts` | +| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | +| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). @@ -104,3 +104,19 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin - How the client identifies the zone room (config id recommended). - Whether exports are per listener (proposed) or a merged all-listeners file. - Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). + +## Share a set (built, behind `fileZoneShare.enabled`) + +The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events. + +Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else. + +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 (the root is already out); a root that fails to send throws before any upload. 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) 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, not inside an open album, and never in an encrypted zone (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. + +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. diff --git a/src/app/features/albums/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts index c4f4fc79..eb59df96 100644 --- a/src/app/features/albums/AlbumView.css.ts +++ b/src/app/features/albums/AlbumView.css.ts @@ -49,3 +49,19 @@ export const FilesWide = style({ }); export const Dim = style({ opacity: config.opacity.P300 }); + +/** The share panel: only mounted once files are picked, so the idle zone shows one row. */ +export const SharePanel = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, +}); + +export const ShareTitle = style({ + all: 'unset', + boxSizing: 'border-box', + minHeight: 44, + width: '100%', + borderBottom: `1px solid ${color.Background.ContainerLine}`, + color: color.Background.OnContainer, +}); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 1bf30579..05acc632 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -16,6 +16,7 @@ import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; import { FileReadBy } from './FileReadBy'; +import { ShareSet, useShareEnabled } from './ShareSet'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -93,6 +94,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const open: Album | undefined = albums.find((a) => a.id === openId); const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; const encrypted = room.hasEncryptionStateEvent(); + const canShare = useShareEnabled(room); let body: React.ReactNode; if (loading) body = Loading albums…; @@ -134,6 +136,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
{body} + {canShare && !open && } diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx new file mode 100644 index 00000000..83060650 --- /dev/null +++ b/src/app/features/albums/ShareSet.tsx @@ -0,0 +1,136 @@ +import React, { useRef, useState } from 'react'; +import { Text } from 'folds'; +import { useQueryClient } from '@tanstack/react-query'; +import { Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { uploadContent } from '../../utils/matrix'; +import { safeFile } from '../../utils/mimeTypes'; +import { getMsgContent } from '../room/msgContent'; +import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; +import { DEFAULT_TITLE, sendShareSet } from './shareSet'; +import * as css from './AlbumView.css'; + +/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ +export const useShareEnabled = (room: Room): boolean => + !!useClientConfig().fileZoneShare?.enabled && !room.hasEncryptionStateEvent(); + +type Phase = { kind: 'idle' } | { kind: 'sending'; done: number } | { kind: 'done'; text: string }; + +/** + * One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked + * names, Send. Sending makes one album of them (shareSet.ts). Nothing here is visible until used. + */ +export function ShareSet({ room }: { room: Room }) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const input = useRef(null); + const [files, setFiles] = useState([]); + const [title, setTitle] = useState(''); + const [phase, setPhase] = useState({ kind: 'idle' }); + + const reset = () => { + setFiles([]); + setTitle(''); + if (input.current) input.current.value = ''; + }; + + const send = async () => { + setPhase({ kind: 'sending', done: 0 }); + let settled = 0; + try { + const res = await sendShareSet( + { + send: async (content) => (await mx.sendMessage(room.roomId, content as any)).event_id, + contentOf: async (f) => { + const file = safeFile(f.file); + const mxc = await new Promise((resolve, reject) => { + uploadContent(mx, file, { onSuccess: resolve, onError: reject }); + }); + const content = await getMsgContent( + mx, + { + file, + originalFile: f.file, + metadata: { markedAsSpoiler: false }, + encInfo: undefined, + }, + mxc + ); + const sha256 = await hashForSend(f.file); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; + return content; + }, + onFile: () => { + settled += 1; + setPhase({ kind: 'sending', done: settled }); + }, + }, + title, + files.map((file) => ({ name: file.name, mimeType: file.type, file })) + ); + queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }); + reset(); + setPhase({ + kind: 'done', + text: res.failed.length + ? `Shared ${res.sent}. Not sent: ${res.failed.join(', ')}` + : `Shared ${res.sent} ${res.sent === 1 ? 'file' : 'files'}.`, + }); + } catch (e: any) { + setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` }); + } + }; + + return ( +
+ { + setFiles(Array.from(e.target.files ?? [])); + setPhase({ kind: 'idle' }); + }} + /> + {files.length === 0 && phase.kind !== 'sending' && ( + + )} + {phase.kind === 'done' && {phase.text}} + {files.length > 0 && ( + <> + setTitle(e.target.value)} + /> + + {files.map((f) => f.name).join(' · ')} + + {phase.kind === 'sending' ? ( + + Sending {phase.done} of {files.length}… + + ) : ( + <> + + + + )} + + )} +
+ ); +} diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts new file mode 100644 index 00000000..41e56ea4 --- /dev/null +++ b/src/app/features/albums/shareSet.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import config from '../../../../config.json'; +import { ZoneEvent, buildAlbums } from './buildAlbums'; +import { DEFAULT_TITLE, ShareFile, SharePort, sendShareSet, threadRelation } from './shareSet'; + +const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file'); + +/** A fake room: records what is sent, as the events Synapse would return from /relations. */ +const room = (failOn: string[] = []) => { + const events: ZoneEvent[] = []; + const port: SharePort = { + send: async (content) => { + const id = `$e${events.length + 1}`; + events.push({ + event_id: id, + type: 'm.room.message', + sender: '@aqua:chat.gamecult.org', + origin_server_ts: 1000 + events.length, + content, + }); + return id; + }, + contentOf: async (f) => { + if (failOn.includes(f.name)) throw new Error('upload failed'); + return { + msgtype: kindOf(f), + body: f.name, + filename: f.name, + url: `mxc://x/${f.name}`, + info: { mimetype: f.mimeType }, + }; + }, + }; + const album = () => { + const [root, ...replies] = events; + return buildAlbums([{ root, replies }], 'chat.gamecult.org')[0]; + }; + return { events, port, album }; +}; + +const clips: ShareFile[] = [ + { name: 'a.ogg', mimeType: 'audio/ogg' }, + { name: 'b.ogg', mimeType: 'audio/ogg' }, + { name: 'c.txt', mimeType: 'text/plain' }, +]; + +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: [] }); + const album = r.album(); + expect(album.title).toBe('Take 4'); + expect(album.files.map((f) => [f.name, f.kind])).toEqual([ + ['a.ogg', 'audio'], + ['b.ogg', 'audio'], + ['c.txt', 'file'], + ]); + }); + + it('threads every file under the root, each falling back to the previous event', async () => { + const r = room(); + await sendShareSet(r.port, 't', clips); + const rels = r.events.slice(1).map((e) => e.content?.['m.relates_to']); + expect(rels).toEqual([ + threadRelation('$e1', '$e1'), + threadRelation('$e1', '$e2'), + threadRelation('$e1', '$e3'), + ]); + expect(r.events[0].content?.['m.relates_to']).toBeUndefined(); + }); + + it('titles an untitled share', async () => { + const r = room(); + await sendShareSet(r.port, ' ', clips.slice(0, 1)); + expect(r.album().title).toBe(DEFAULT_TITLE); + }); + + it('names a failed file and still sends the rest', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet(r.port, 't', clips); + expect(res.failed).toEqual(['b.ogg']); + expect(res.sent).toBe(2); + expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg', 'c.txt']); + // the next file falls back to the last event that exists, not to the failed one + expect(r.events[2].content?.['m.relates_to']).toEqual(threadRelation('$e1', '$e2')); + }); + + it('throws when the root cannot be sent, uploading nothing', async () => { + let uploads = 0; + const port: SharePort = { + send: async () => { + throw new Error('M_FORBIDDEN'); + }, + contentOf: async () => { + uploads += 1; + return {}; + }, + }; + await expect(sendShareSet(port, 't', clips)).rejects.toThrow('M_FORBIDDEN'); + expect(uploads).toBe(0); + }); +}); + +describe('the share flag', () => { + it('ships off', () => { + expect(config.fileZoneShare).toEqual({ enabled: false }); + }); +}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts new file mode 100644 index 00000000..c7aeeb21 --- /dev/null +++ b/src/app/features/albums/shareSet.ts @@ -0,0 +1,65 @@ +// Share a set of files at once: one album root (a text message) and one thread reply per file. +// That is exactly the shape buildAlbums reads, so a set shared here and a set a Mind's mouth posts +// are the same thing to every viewer. Ports are injected so the order and the failure rules are +// testable without a homeserver. See docs/file-zone.md, "Share a set". + +export type ShareFile = { name: string; mimeType: string }; + +/** What sendShareSet needs from the outside world. */ +export type SharePort = { + /** Send an event and return its id. */ + send: (content: Record) => Promise; + /** Upload one file and build its message content (msgtype, url, info incl. sha256). */ + contentOf: (file: F) => Promise>; + /** Called after each file settles, for progress. */ + onFile?: (index: number, result: 'sent' | 'failed') => void; +}; + +export type ShareResult = { + /** The album root's event id; present as soon as the root is sent. */ + albumId: string; + sent: number; + failed: string[]; +}; + +/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */ +export const threadRelation = (rootId: string, lastId: string) => ({ + rel_type: 'm.thread', + event_id: rootId, + is_falling_back: true, + 'm.in_reply_to': { event_id: lastId }, +}); + +export const DEFAULT_TITLE = 'Shared files'; + +export const titleOrDefault = (title: string): string => title.trim() || DEFAULT_TITLE; + +/** + * Files go one at a time, in the order given, so thread order is pick order. A file that fails + * to upload is reported by name and the rest still go; the root is already out, so stopping would + * leave an empty album and the sender nothing to retry from. A root that fails to send throws: + * nothing exists yet. + */ +export const sendShareSet = async ( + port: SharePort, + title: string, + files: F[] +): Promise => { + const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) }); + const result: ShareResult = { albumId, sent: 0, failed: [] }; + let lastId = albumId; + for (let i = 0; i < files.length; i += 1) { + try { + // eslint-disable-next-line no-await-in-loop + const content = await port.contentOf(files[i]); + // eslint-disable-next-line no-await-in-loop + lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) }); + result.sent += 1; + port.onFile?.(i, 'sent'); + } catch { + result.failed.push(files[i].name); + port.onFile?.(i, 'failed'); + } + } + return result; +}; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 3f0a45d4..b4483d8b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -93,12 +93,7 @@ import { safeFile } from '../../utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { - getAudioMsgContent, - getFileMsgContent, - getImageMsgContent, - getVideoMsgContent, -} from './msgContent'; +import { getMsgContent } from './msgContent'; import { FILE_HASH_KEY, hashForSend } from '../albums/fileReceipt'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; @@ -281,16 +276,7 @@ export const RoomInput = forwardRef( const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); - let content: IContent; - if (fileItem.file.type.startsWith('image')) { - content = await getImageMsgContent(mx, fileItem, upload.mxc); - } else if (fileItem.file.type.startsWith('video')) { - content = await getVideoMsgContent(mx, fileItem, upload.mxc); - } else if (fileItem.file.type.startsWith('audio')) { - content = getAudioMsgContent(fileItem, upload.mxc); - } else { - content = getFileMsgContent(fileItem, upload.mxc); - } + const content = await getMsgContent(mx, fileItem, upload.mxc); // A Mind verifies what it fetched against this. An encrypted file already carries the // ciphertext hash in file.hashes; a plaintext hash would leak the file's identity. if (!fileItem.encInfo) { diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 5b7cd145..d6cd6b93 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -169,3 +169,16 @@ export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => { } return content; }; + +/** The message content for an uploaded file, by its type. */ +export const getMsgContent = async ( + mx: MatrixClient, + item: TUploadItem, + mxc: string +): Promise => { + const { type } = item.file; + if (type.startsWith('image')) return getImageMsgContent(mx, item, mxc); + if (type.startsWith('video')) return getVideoMsgContent(mx, item, mxc); + if (type.startsWith('audio')) return getAudioMsgContent(item, mxc); + return getFileMsgContent(item, mxc); +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7f0899be..2ba69290 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -34,6 +34,9 @@ export type ClientConfig = { // Rooms (ids or aliases) shown as the album view; a room whose create type is org.gamecult.file_zone needs no entry. fileZoneRooms?: string[]; + // Sharing a set of files into a file-zone room as one album. Off unless `enabled` is true. + fileZoneShare?: { enabled?: boolean }; + // The sign-up entry on the sign-in page. Off unless `enabled` is true; `url` is the account // service's own registration page (a value, never a route this client invents). registration?: RegistrationConfig; -- 2.47.3 From 1e47b155f11e82bd72222e0e7742f8b23ba5c476 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 12:59:25 +0000 Subject: [PATCH 2/3] File zone share: keep failed files picked, one send at a time, encrypted re-check, stay mounted while an album is open, wrap long names Co-Authored-By: Claude Sonnet 5.5 --- config.json | 12 ++++++--- docs/file-zone.md | 4 +-- src/app/features/albums/AlbumView.css.ts | 6 ++++- src/app/features/albums/AlbumView.tsx | 7 ++++- src/app/features/albums/ShareSet.tsx | 30 +++++++++++++-------- src/app/features/albums/shareSet.test.ts | 33 +++++++++++++++++++++++- src/app/features/albums/shareSet.ts | 15 +++++++++++ 7 files changed, 88 insertions(+), 19 deletions(-) diff --git a/config.json b/config.json index 93396d91..f98575d9 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,18 @@ { "defaultHomeserver": 0, - "homeserverList": ["chat.gamecult.org"], + "homeserverList": [ + "chat.gamecult.org" + ], "allowCustomHomeservers": false, "featuredCommunities": { "openAsDefault": false, - "spaces": ["#gamecult:gamecult.org"], + "spaces": [ + "#gamecult:gamecult.org" + ], "rooms": [], - "servers": ["gamecult.org"] + "servers": [ + "gamecult.org" + ] }, "hashRouter": { "enabled": true, diff --git a/docs/file-zone.md b/docs/file-zone.md index a4e2f102..af4f82a3 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -113,8 +113,8 @@ 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 (the root is already out); a root that fails to send throws before any upload. 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) 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, not inside an open album, and never in an encrypted zone (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only). +- `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). - 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/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts index eb59df96..d60dba43 100644 --- a/src/app/features/albums/AlbumView.css.ts +++ b/src/app/features/albums/AlbumView.css.ts @@ -50,7 +50,7 @@ export const FilesWide = style({ export const Dim = style({ opacity: config.opacity.P300 }); -/** The share panel: only mounted once files are picked, so the idle zone shows one row. */ +/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */ export const SharePanel = style({ display: 'flex', flexDirection: 'column', @@ -64,4 +64,8 @@ export const ShareTitle = style({ width: '100%', borderBottom: `1px solid ${color.Background.ContainerLine}`, color: color.Background.OnContainer, + ':focus-visible': { borderBottomColor: color.Background.OnContainer }, }); + +/** Picked names and send notes wrap, even a long unbroken name. */ +export const ShareNames = style({ overflowWrap: 'anywhere' }); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 05acc632..b670ee2b 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -136,7 +136,12 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
{body} - {canShare && !open && } + {/* Stays mounted while an album is open, so a running send keeps its progress. */} + {canShare && ( + + )} diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index 83060650..3eb139da 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -8,7 +8,7 @@ import { uploadContent } from '../../utils/matrix'; import { safeFile } from '../../utils/mimeTypes'; import { getMsgContent } from '../room/msgContent'; import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; -import { DEFAULT_TITLE, sendShareSet } from './shareSet'; +import { DEFAULT_TITLE, remainingAfter, resultNote, sendShareSet } from './shareSet'; import * as css from './AlbumView.css'; /** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ @@ -25,6 +25,7 @@ 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' }); @@ -36,6 +37,10 @@ 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; + const items = files.map((file) => ({ name: file.name, mimeType: file.type, file })); setPhase({ kind: 'sending', done: 0 }); let settled = 0; try { @@ -67,18 +72,17 @@ export function ShareSet({ room }: { room: Room }) { }, }, title, - files.map((file) => ({ name: file.name, mimeType: file.type, file })) + items ); queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }); - reset(); - setPhase({ - kind: 'done', - text: res.failed.length - ? `Shared ${res.sent}. Not sent: ${res.failed.join(', ')}` - : `Shared ${res.sent} ${res.sent === 1 ? 'file' : 'files'}.`, - }); + const left = remainingAfter(items, res).map((i) => i.file); + setFiles(left); + if (left.length === 0) reset(); + setPhase({ kind: 'done', text: resultNote(res) }); } catch (e: any) { setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` }); + } finally { + busy.current = false; } }; @@ -99,7 +103,11 @@ export function ShareSet({ room }: { room: Room }) { Share files › )} - {phase.kind === 'done' && {phase.text}} + {phase.kind === 'done' && ( + + {phase.text} + + )} {files.length > 0 && ( <> setTitle(e.target.value)} /> - + {files.map((f) => f.name).join(' · ')} {phase.kind === 'sending' ? ( diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts index 41e56ea4..3ac5f095 100644 --- a/src/app/features/albums/shareSet.test.ts +++ b/src/app/features/albums/shareSet.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest'; import config from '../../../../config.json'; import { ZoneEvent, buildAlbums } from './buildAlbums'; -import { DEFAULT_TITLE, ShareFile, SharePort, sendShareSet, threadRelation } from './shareSet'; +import { + DEFAULT_TITLE, + ShareFile, + SharePort, + remainingAfter, + resultNote, + sendShareSet, + threadRelation, +} from './shareSet'; const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file'); @@ -107,3 +115,26 @@ describe('the share flag', () => { expect(config.fileZoneShare).toEqual({ enabled: false }); }); }); + +describe('after a send', () => { + 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); + expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg']); + expect(resultNote(res)).toBe('Shared 2. Not sent: b.ogg'); + }); + + it('keeps everything and says nothing was shared when every upload failed', async () => { + const r = room(clips.map((c) => c.name)); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual(clips); + expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg, b.ogg, c.txt'); + }); + + it('clears the pick on full success', async () => { + const r = room(); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual([]); + expect(resultNote(res)).toBe('Shared 3 files.'); + }); +}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts index c7aeeb21..5d654f0c 100644 --- a/src/app/features/albums/shareSet.ts +++ b/src/app/features/albums/shareSet.ts @@ -63,3 +63,18 @@ export const sendShareSet = async ( } return result; }; + +/** + * 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 + * 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)); + +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(', ')}`; +}; -- 2.47.3 From 1a0882c0834e7a48cf6040ab68add5b036ca054d Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 13:01:35 +0000 Subject: [PATCH 3/3] 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) -- 2.47.3