File zone share: component tests, state machine moved out of ShareSet #116
6 changed files with 553 additions and 86 deletions
|
|
@ -114,6 +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`).
|
||||
- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests.
|
||||
- `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`.
|
||||
|
||||
|
|
|
|||
108
src/app/features/albums/ShareSet.test.tsx
Normal file
108
src/app/features/albums/ShareSet.test.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ClientConfigProvider } from '../../hooks/useClientConfig';
|
||||
import type { ClientConfig } from '../../hooks/useClientConfig';
|
||||
import { ShareState } from './shareController';
|
||||
import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet';
|
||||
|
||||
const text = (html: string) =>
|
||||
html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const noop = () => undefined;
|
||||
const file = (name: string) => ({ name, type: 'audio/ogg' } as File);
|
||||
|
||||
const view = (over: Partial<ShareState<File>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
<ShareSetView
|
||||
state={{ files: [], title: '', phase: { kind: 'idle' }, epoch: 0, ...over }}
|
||||
onPickClick={noop}
|
||||
onPicked={noop}
|
||||
onTitle={noop}
|
||||
onSend={noop}
|
||||
onCancel={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
describe('the share panel, drawn', () => {
|
||||
it('idle: one "Share files" row and no title, names, Send or Cancel', () => {
|
||||
const html = view();
|
||||
expect(text(html)).toBe('Share files ›');
|
||||
expect(html).not.toContain('aria-label="Album title"');
|
||||
expect(html).toContain('type="file"');
|
||||
expect(html).toContain('multiple');
|
||||
expect(html).toContain('hidden');
|
||||
});
|
||||
|
||||
it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => {
|
||||
const html = view({ files: [file('a.ogg'), file('b.ogg')] });
|
||||
expect(html).toContain('aria-label="Album title"');
|
||||
expect(html).toContain('placeholder="Shared files"');
|
||||
expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel');
|
||||
expect(text(html)).not.toContain('Share files');
|
||||
});
|
||||
|
||||
it('one picked file reads "Send 1 file"', () => {
|
||||
expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel');
|
||||
});
|
||||
|
||||
it('sending: progress replaces Send and Cancel, and the title is locked', () => {
|
||||
const html = view({
|
||||
files: [file('a'), file('b'), file('c')],
|
||||
phase: { kind: 'sending', done: 2 },
|
||||
});
|
||||
expect(text(html)).toContain('Sending 2 of 3…');
|
||||
expect(text(html)).not.toContain('Send 3 files');
|
||||
expect(text(html)).not.toContain('Cancel');
|
||||
expect(html).toMatch(
|
||||
/aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/
|
||||
);
|
||||
});
|
||||
|
||||
it('the encrypted note shows above the kept pick', () => {
|
||||
const html = view({
|
||||
files: [file('a.ogg')],
|
||||
phase: { kind: 'done', text: 'Not shared: this room is encrypted.' },
|
||||
});
|
||||
expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel');
|
||||
});
|
||||
|
||||
it('after a full success the idle row and the note show together', () => {
|
||||
expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe(
|
||||
'Share files › Shared 2 files.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the flag', () => {
|
||||
it('off, missing or empty offers no share', () => {
|
||||
expect(shareEnabled({}, false)).toBe(false);
|
||||
expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false);
|
||||
expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('on offers it, except in an encrypted room', () => {
|
||||
expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true);
|
||||
expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('the hook reads the client config and the room, as AlbumView mounts it', () => {
|
||||
const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never);
|
||||
function Probe({ encrypted }: { encrypted: boolean }) {
|
||||
return <span>{String(useShareEnabled(room(encrypted)))}</span>;
|
||||
}
|
||||
const probe = (config: ClientConfig, encrypted: boolean) =>
|
||||
text(
|
||||
renderToStaticMarkup(
|
||||
<ClientConfigProvider value={config}>
|
||||
<Probe encrypted={encrypted} />
|
||||
</ClientConfigProvider>
|
||||
)
|
||||
);
|
||||
expect(probe({}, false)).toBe('false');
|
||||
expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true');
|
||||
expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import React, { useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { Text } from 'folds';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
|
|
@ -8,104 +8,55 @@ 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 { ShareController, ShareState, createShareController } from './shareController';
|
||||
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 shareEnabled = (
|
||||
config: { fileZoneShare?: { enabled?: boolean } },
|
||||
encrypted: boolean
|
||||
) => !!config.fileZoneShare?.enabled && !encrypted;
|
||||
|
||||
export const useShareEnabled = (room: Room): boolean =>
|
||||
!!useClientConfig().fileZoneShare?.enabled && !room.hasEncryptionStateEvent();
|
||||
shareEnabled(useClientConfig(), 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 };
|
||||
type ShareViewProps = {
|
||||
state: ShareState<File>;
|
||||
onPickClick: () => void;
|
||||
onPicked: (files: File[]) => void;
|
||||
onTitle: (title: string) => void;
|
||||
onSend: () => void;
|
||||
onCancel: () => void;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* names, Send. Nothing but the row is visible until files are picked.
|
||||
*/
|
||||
export function ShareSet({ room }: { room: Room }) {
|
||||
const mx = useMatrixClient();
|
||||
const queryClient = useQueryClient();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [phase, setPhase] = useState<Phase>({ 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<string>((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);
|
||||
}
|
||||
};
|
||||
|
||||
export function ShareSetView({
|
||||
state,
|
||||
onPickClick,
|
||||
onPicked,
|
||||
onTitle,
|
||||
onSend,
|
||||
onCancel,
|
||||
inputRef,
|
||||
}: ShareViewProps) {
|
||||
const { files, title, phase, epoch } = state;
|
||||
return (
|
||||
<div className={css.SharePanel}>
|
||||
<input
|
||||
ref={input}
|
||||
key={epoch}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
setFiles(Array.from(e.target.files ?? []));
|
||||
setPhase({ kind: 'idle' });
|
||||
}}
|
||||
onChange={(e) => onPicked(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
{files.length === 0 && phase.kind !== 'sending' && (
|
||||
<button type="button" className={css.Row} onClick={() => input.current?.click()}>
|
||||
<button type="button" className={css.Row} onClick={onPickClick}>
|
||||
<Text size="B400">Share files ›</Text>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -122,7 +73,7 @@ export function ShareSet({ room }: { room: Room }) {
|
|||
placeholder={DEFAULT_TITLE}
|
||||
aria-label="Album title"
|
||||
disabled={phase.kind === 'sending'}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={(e) => onTitle(e.target.value)}
|
||||
/>
|
||||
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
|
||||
{files.map((f) => f.name).join(' · ')}
|
||||
|
|
@ -133,12 +84,12 @@ export function ShareSet({ room }: { room: Room }) {
|
|||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={css.Row} onClick={send}>
|
||||
<button type="button" className={css.Row} onClick={onSend}>
|
||||
<Text size="B400">
|
||||
Send {files.length} {files.length === 1 ? 'file' : 'files'}
|
||||
</Text>
|
||||
</button>
|
||||
<button type="button" className={css.Row} onClick={reset}>
|
||||
<button type="button" className={css.Row} onClick={onCancel}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</button>
|
||||
</>
|
||||
|
|
@ -148,3 +99,62 @@ export function ShareSet({ room }: { room: Room }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareSet({ room }: { room: Room }) {
|
||||
const mx = useMatrixClient();
|
||||
const queryClient = useQueryClient();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
// Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a
|
||||
// rerender with a new client or Room object must not build another. Live values go through `live`.
|
||||
const live = useRef({ mx, room, queryClient });
|
||||
live.current = { mx, room, queryClient };
|
||||
const [controller]: [ShareController<File>, unknown] = useState(() =>
|
||||
createShareController<File>({
|
||||
roomId: room.roomId,
|
||||
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
|
||||
onSent: () =>
|
||||
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
|
||||
run: (files, title, onFile) =>
|
||||
sendShareSet(
|
||||
{
|
||||
send: async (content) =>
|
||||
(await live.current.mx.sendMessage(room.roomId, content as any)).event_id,
|
||||
contentOf: async (f) => {
|
||||
const file = safeFile(f.file);
|
||||
const mxc = await new Promise<string>((resolve, reject) => {
|
||||
uploadContent(live.current.mx, file, { onSuccess: resolve, onError: reject });
|
||||
});
|
||||
const content = await getMsgContent(
|
||||
live.current.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,
|
||||
},
|
||||
title,
|
||||
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
|
||||
),
|
||||
})
|
||||
);
|
||||
const state = useSyncExternalStore(controller.subscribe, controller.getState);
|
||||
return (
|
||||
<ShareSetView
|
||||
state={state}
|
||||
inputRef={input}
|
||||
onPickClick={() => input.current?.click()}
|
||||
onPicked={controller.pick}
|
||||
onTitle={controller.setTitle}
|
||||
onSend={controller.send}
|
||||
onCancel={controller.cancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
261
src/app/features/albums/shareController.test.ts
Normal file
261
src/app/features/albums/shareController.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { Picked, ShareDeps, createShareController } from './shareController';
|
||||
import { ShareResult } from './shareSet';
|
||||
|
||||
const file = (name: string): Picked => ({ name, type: 'audio/ogg' });
|
||||
const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] });
|
||||
|
||||
/** A run the test settles by hand, so "in flight" is a state the test can stand in. */
|
||||
const hand = () => {
|
||||
const calls: { files: Picked[]; title: string; onFile: () => void }[] = [];
|
||||
let release: (r: ShareResult) => void = () => undefined;
|
||||
let fail: (e: Error) => void = () => undefined;
|
||||
const run: ShareDeps<Picked>['run'] = (files, title, onFile) => {
|
||||
calls.push({ files, title, onFile });
|
||||
return new Promise<ShareResult>((resolve, reject) => {
|
||||
release = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
};
|
||||
return { calls, run, release: (r: ShareResult) => release(r), fail: (e: Error) => fail(e) };
|
||||
};
|
||||
|
||||
const make = (over: Partial<ShareDeps<Picked>> = {}, roomId = '!zone:x') => {
|
||||
const h = hand();
|
||||
const sentTo: string[] = [];
|
||||
const c = createShareController<Picked>({
|
||||
roomId,
|
||||
isEncrypted: () => false,
|
||||
run: h.run,
|
||||
onSent: () => sentTo.push(roomId),
|
||||
...over,
|
||||
});
|
||||
return { c, h, sentTo };
|
||||
};
|
||||
|
||||
describe('picking', () => {
|
||||
it('starts idle with nothing picked, and a pick holds the files in order', () => {
|
||||
const { c } = make();
|
||||
expect(c.getState().files).toEqual([]);
|
||||
expect(c.getState().phase).toEqual({ kind: 'idle' });
|
||||
c.pick([file('a'), file('b')]);
|
||||
expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('Cancel empties the pick and the title and bumps the input epoch', () => {
|
||||
const { c } = make();
|
||||
c.pick([file('a')]);
|
||||
c.setTitle('Take 4');
|
||||
const before = c.getState().epoch;
|
||||
c.cancel();
|
||||
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: before + 1 });
|
||||
});
|
||||
|
||||
it('subscribers hear every change and stop hearing after unsubscribe', () => {
|
||||
const { c } = make();
|
||||
let heard = 0;
|
||||
const off = c.subscribe(() => {
|
||||
heard += 1;
|
||||
});
|
||||
c.pick([file('a')]);
|
||||
off();
|
||||
c.pick([]);
|
||||
expect(heard).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Send guard', () => {
|
||||
it('a double tap starts one send', async () => {
|
||||
const { c, h } = make();
|
||||
c.pick([file('a'), file('b')]);
|
||||
const first = c.send();
|
||||
const second = c.send();
|
||||
expect(h.calls).toHaveLength(1);
|
||||
h.release(ok(2));
|
||||
await Promise.all([first, second]);
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a re-mounted panel for the same room cannot start a second send while one is in flight', async () => {
|
||||
const { c, h } = make();
|
||||
c.pick([file('a')]);
|
||||
const first = c.send();
|
||||
// The page was left and returned to: a new controller, nothing picked in it but a new pick.
|
||||
const again = make({}, '!zone:x');
|
||||
again.c.pick([file('b')]);
|
||||
await again.c.send();
|
||||
expect(again.h.calls).toHaveLength(0);
|
||||
h.release(ok(1));
|
||||
await first;
|
||||
// Once the first has settled the room is free again.
|
||||
const next = again.c.send();
|
||||
expect(again.h.calls).toHaveLength(1);
|
||||
again.h.release(ok(1));
|
||||
await next;
|
||||
});
|
||||
|
||||
it('another room is not blocked by a send in flight', async () => {
|
||||
const a = make({}, '!a:x');
|
||||
const b = make({}, '!b:x');
|
||||
a.c.pick([file('a')]);
|
||||
b.c.pick([file('b')]);
|
||||
const sendA = a.c.send();
|
||||
const sendB = b.c.send();
|
||||
expect(b.h.calls).toHaveLength(1);
|
||||
a.h.release(ok(1));
|
||||
b.h.release(ok(1));
|
||||
await Promise.all([sendA, sendB]);
|
||||
});
|
||||
|
||||
it('sends nothing when nothing is picked', async () => {
|
||||
const { c, h } = make();
|
||||
await c.send();
|
||||
expect(h.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a send that throws frees the room and says so', async () => {
|
||||
const { c, h } = make();
|
||||
c.pick([file('a')]);
|
||||
const p = c.send();
|
||||
h.fail(new Error('M_FORBIDDEN'));
|
||||
await p;
|
||||
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Could not share: M_FORBIDDEN' });
|
||||
expect(c.getState().files).toHaveLength(1);
|
||||
const retry = c.send();
|
||||
expect(h.calls).toHaveLength(2);
|
||||
h.release(ok(1));
|
||||
await retry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress and results', () => {
|
||||
it('counts files as they settle, passing the title through', async () => {
|
||||
const { c, h } = make();
|
||||
c.pick([file('a'), file('b')]);
|
||||
c.setTitle('Take 4');
|
||||
const p = c.send();
|
||||
expect(c.getState().phase).toEqual({ kind: 'sending', done: 0 });
|
||||
expect(h.calls[0].title).toBe('Take 4');
|
||||
h.calls[0].onFile();
|
||||
expect(c.getState().phase).toEqual({ kind: 'sending', done: 1 });
|
||||
h.release(ok(2));
|
||||
await p;
|
||||
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2 files.' });
|
||||
});
|
||||
|
||||
it('full success clears the pick, tells the room list, and remounts the input', async () => {
|
||||
const { c, h, sentTo } = make();
|
||||
c.pick([file('a')]);
|
||||
const { epoch } = c.getState();
|
||||
const p = c.send();
|
||||
h.release(ok(1));
|
||||
await p;
|
||||
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: epoch + 1 });
|
||||
expect(sentTo).toEqual(['!zone:x']);
|
||||
});
|
||||
|
||||
it('failed files stay picked by position, so a same-named twin that went is not re-sent', async () => {
|
||||
const { c, h } = make();
|
||||
const twins = [file('a.ogg'), file('a.ogg'), file('b.ogg')];
|
||||
c.pick(twins);
|
||||
const p = c.send();
|
||||
h.release({ albumId: '$r', sent: 2, failed: ['a.ogg'], failedAt: [1] });
|
||||
await p;
|
||||
expect(c.getState().files).toEqual([twins[1]]);
|
||||
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2. Not sent: a.ogg' });
|
||||
});
|
||||
|
||||
it('when every upload failed everything stays picked and the note says nothing was shared', async () => {
|
||||
const { c, h } = make();
|
||||
const picked = [file('a'), file('b')];
|
||||
c.pick(picked);
|
||||
const p = c.send();
|
||||
h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] });
|
||||
await p;
|
||||
expect(c.getState().files).toEqual(picked);
|
||||
expect(c.getState().phase).toEqual({
|
||||
kind: 'done',
|
||||
text: 'Nothing was shared. Not sent: a, b',
|
||||
});
|
||||
});
|
||||
|
||||
it('a new pick after a result drops the result note', async () => {
|
||||
const { c, h } = make();
|
||||
c.pick([file('a')]);
|
||||
const p = c.send();
|
||||
h.release(ok(1));
|
||||
await p;
|
||||
c.pick([file('b')]);
|
||||
expect(c.getState().phase).toEqual({ kind: 'idle' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a send leaves behind', () => {
|
||||
it('keeps the title when some files failed, so the retry has the same name', async () => {
|
||||
const { c, h } = make();
|
||||
c.pick([file('a'), file('b')]);
|
||||
c.setTitle('Take 4');
|
||||
const p = c.send();
|
||||
h.release({ albumId: '$r', sent: 1, failed: ['b'], failedAt: [1] });
|
||||
await p;
|
||||
expect(c.getState().title).toBe('Take 4');
|
||||
});
|
||||
|
||||
it('tells the room list only after the send settled, and not when it threw', async () => {
|
||||
const { c, h, sentTo } = make();
|
||||
c.pick([file('a')]);
|
||||
const p = c.send();
|
||||
expect(sentTo).toEqual([]);
|
||||
h.release(ok(1));
|
||||
await p;
|
||||
expect(sentTo).toEqual(['!zone:x']);
|
||||
|
||||
const bad = make();
|
||||
bad.c.pick([file('a')]);
|
||||
const q = bad.c.send();
|
||||
bad.h.fail(new Error('boom'));
|
||||
await q;
|
||||
expect(bad.sentTo).toEqual([]);
|
||||
});
|
||||
|
||||
it('a success lands in one notification: the pick is never empty while still sending', async () => {
|
||||
const { c, h } = make();
|
||||
c.pick([file('a')]);
|
||||
const p = c.send();
|
||||
const seen: string[] = [];
|
||||
c.subscribe(() => {
|
||||
const st = c.getState();
|
||||
seen.push(`${st.files.length}:${st.phase.kind}`);
|
||||
});
|
||||
h.release(ok(1));
|
||||
await p;
|
||||
expect(seen).toEqual(['0:done']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an encrypted room', () => {
|
||||
it('says so, sends nothing and keeps the pick', async () => {
|
||||
const { c, h } = make({ isEncrypted: () => true });
|
||||
c.pick([file('a')]);
|
||||
await c.send();
|
||||
expect(c.getState().phase).toEqual({
|
||||
kind: 'done',
|
||||
text: 'Not shared: this room is encrypted.',
|
||||
});
|
||||
expect(c.getState().files).toHaveLength(1);
|
||||
expect(h.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is checked at Send, not at mount: a room that turns encrypted after the pick is refused', async () => {
|
||||
let encrypted = false;
|
||||
const { c, h } = make({ isEncrypted: () => encrypted });
|
||||
c.pick([file('a')]);
|
||||
encrypted = true;
|
||||
await c.send();
|
||||
expect(h.calls).toHaveLength(0);
|
||||
expect(c.getState().phase).toMatchObject({
|
||||
kind: 'done',
|
||||
text: 'Not shared: this room is encrypted.',
|
||||
});
|
||||
});
|
||||
});
|
||||
87
src/app/features/albums/shareController.ts
Normal file
87
src/app/features/albums/shareController.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// The share panel's state machine, apart from React and Matrix: what is picked, what phase the
|
||||
// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it.
|
||||
import { ShareResult, remainingAfter, resultNote } from './shareSet';
|
||||
|
||||
export type Picked = { name: string; type: string };
|
||||
|
||||
export type SharePhase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'sending'; done: number }
|
||||
| { kind: 'done'; text: string };
|
||||
|
||||
export type ShareState<F extends Picked> = {
|
||||
files: F[];
|
||||
title: string;
|
||||
phase: SharePhase;
|
||||
/** Bumps whenever the pick is emptied, so the file input can be remounted and re-pick the same files. */
|
||||
epoch: number;
|
||||
};
|
||||
|
||||
export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.';
|
||||
|
||||
export type ShareDeps<F extends Picked> = {
|
||||
roomId: string;
|
||||
isEncrypted: () => boolean;
|
||||
/** Sends the files as one album; reports each settled file through onFile. */
|
||||
run: (files: F[], title: string, onFile: () => void) => Promise<ShareResult>;
|
||||
/** Called after a send that reached the room, whatever it left unsent. */
|
||||
onSent?: () => void;
|
||||
};
|
||||
|
||||
/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */
|
||||
const sending = new Set<string>();
|
||||
|
||||
export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
|
||||
let state: ShareState<F> = { files: [], title: '', phase: { kind: 'idle' }, epoch: 0 };
|
||||
const listeners = new Set<() => void>();
|
||||
const set = (patch: Partial<ShareState<F>>) => {
|
||||
state = { ...state, ...patch };
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
const clear = () => set({ files: [], title: '', epoch: state.epoch + 1 });
|
||||
|
||||
const send = async () => {
|
||||
if (sending.has(deps.roomId) || state.files.length === 0) return;
|
||||
if (deps.isEncrypted()) {
|
||||
set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } });
|
||||
return;
|
||||
}
|
||||
sending.add(deps.roomId);
|
||||
const { files, title } = state;
|
||||
set({ phase: { kind: 'sending', done: 0 } });
|
||||
let settled = 0;
|
||||
try {
|
||||
const res = await deps.run(files, title, () => {
|
||||
settled += 1;
|
||||
set({ phase: { kind: 'sending', done: settled } });
|
||||
});
|
||||
deps.onSent?.();
|
||||
const left = remainingAfter(files, res);
|
||||
const phase: SharePhase = { kind: 'done', text: resultNote(res) };
|
||||
// One notification: no state is ever drawn with the pick emptied and the send still running.
|
||||
if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase });
|
||||
else set({ files: left, phase });
|
||||
} catch (e: any) {
|
||||
set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } });
|
||||
} finally {
|
||||
sending.delete(deps.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
},
|
||||
pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }),
|
||||
setTitle: (title: string) => set({ title }),
|
||||
cancel: clear,
|
||||
send,
|
||||
};
|
||||
};
|
||||
|
||||
export type ShareController<F extends Picked> = ReturnType<typeof createShareController<F>>;
|
||||
|
|
@ -72,7 +72,7 @@ export const sendShareSet = async <F extends ShareFile>(
|
|||
* 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[] =>
|
||||
export const remainingAfter = <T>(files: T[], result: ShareResult): T[] =>
|
||||
files.filter((_, i) => result.failedAt.includes(i));
|
||||
|
||||
export const resultNote = (result: ShareResult): string => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue