diff --git a/config.json b/config.json
index ef2a7a94..f98575d9 100644
--- a/config.json
+++ b/config.json
@@ -25,6 +25,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..7f380d69 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; 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 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.
+
+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..d60dba43 100644
--- a/src/app/features/albums/AlbumView.css.ts
+++ b/src/app/features/albums/AlbumView.css.ts
@@ -49,3 +49,23 @@ export const FilesWide = style({
});
export const Dim = style({ opacity: config.opacity.P300 });
+
+/** 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',
+ 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,
+ ':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 1bf30579..b670ee2b 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,12 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
{body}
+ {/* 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
new file mode 100644
index 00000000..1d9c4302
--- /dev/null
+++ b/src/app/features/albums/ShareSet.tsx
@@ -0,0 +1,150 @@
+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, 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. */
+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 };
+
+/**
+ * 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 () => {
+ // One send at a time, and never into a room that has turned encrypted since the panel mounted.
+ 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;
+ 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,
+ items
+ );
+ queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] });
+ 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 {
+ sending.delete(room.roomId);
+ }
+ };
+
+ return (
+