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
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:
parent
8ba931186f
commit
1e47b155f1
7 changed files with 88 additions and 19 deletions
12
config.json
12
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(', ')}`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue