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
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-12 12:59:25 +00:00
commit 1e47b155f1
7 changed files with 88 additions and 19 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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' });

View file

@ -136,7 +136,12 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
<Scroll hideTrack visibility="Hover">
<div className={css.Column}>
{body}
{canShare && !open && <ShareSet room={room} />}
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
{canShare && (
<div hidden={!!open}>
<ShareSet room={room} />
</div>
)}
<button type="button" className={css.Row} onClick={onTimeline}>
<Text size="B400">Timeline ›</Text>
</button>

View file

@ -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<HTMLInputElement>(null);
const busy = useRef(false);
const [files, setFiles] = useState<File[]>([]);
const [title, setTitle] = useState('');
const [phase, setPhase] = useState<Phase>({ 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 }) {
<Text size="B400">Share files ›</Text>
</button>
)}
{phase.kind === 'done' && <Text size="T200">{phase.text}</Text>}
{phase.kind === 'done' && (
<Text size="T200" className={css.ShareNames}>
{phase.text}
</Text>
)}
{files.length > 0 && (
<>
<input
@ -110,7 +118,7 @@ export function ShareSet({ room }: { room: Room }) {
disabled={phase.kind === 'sending'}
onChange={(e) => setTitle(e.target.value)}
/>
<Text size="T200" className={css.Dim}>
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
{files.map((f) => f.name).join(' · ')}
</Text>
{phase.kind === 'sending' ? (

View file

@ -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.');
});
});

View file

@ -63,3 +63,18 @@ export const sendShareSet = async <F extends ShareFile>(
}
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 = <F extends ShareFile>(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(', ')}`;
};