File zone share: failed files by position, send guard outside the component, say when the room turned encrypted
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m33s
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e47b155f1
commit
1a0882c083
4 changed files with 37 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
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<HTMLInputElement>(null);
|
||||
const busy = useRef(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [phase, setPhase] = useState<Phase>({ 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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ShareFile> = {
|
||||
...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);
|
||||
|
|
|
|||
|
|
@ -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 <F extends ShareFile>(
|
|||
files: F[]
|
||||
): Promise<ShareResult> => {
|
||||
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 <F extends ShareFile>(
|
|||
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 <F extends ShareFile>(
|
|||
* 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));
|
||||
files.filter((_, i) => result.failedAt.includes(i));
|
||||
|
||||
export const resultNote = (result: ShareResult): string => {
|
||||
if (result.failed.length === 0)
|
||||
|
|
|
|||
Loading…
Reference in a new issue