diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md
index 511b0dbc..7b06bb9d 100644
--- a/docs/commons-files-desk.md
+++ b/docs/commons-files-desk.md
@@ -124,7 +124,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C
2. **Pagination** of albums and files in `useAlbums` (done, PR #148).
3. **Folders by title path** and the album list grouped by it.
4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149).
-5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); cancel of a send in flight is not, and needs an abort path through `uploadContent`.
+5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`.
PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake.
diff --git a/docs/file-zone.md b/docs/file-zone.md
index 0a895226..420f7016 100644
--- a/docs/file-zone.md
+++ b/docs/file-zone.md
@@ -120,4 +120,4 @@ Client, `src/app/features/albums`:
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; cancel of a send in flight (a pick can be cancelled before it goes). Built since: pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).
+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; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).
diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx
index fc76744a..d6ee37c9 100644
--- a/src/app/features/albums/AlbumView.tsx
+++ b/src/app/features/albums/AlbumView.tsx
@@ -14,7 +14,7 @@ import { markAsRead } from '../../utils/notifications';
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
-import { Album, AlbumFile, lastEventId } from './buildAlbums';
+import { Album, AlbumFile, lastEvent } from './buildAlbums';
import { folderOf, listFolder, pathLabel } from './albumFolders';
import { FileReadBy } from './FileReadBy';
import { ShareSet, useShareEnabled } from './ShareSet';
@@ -146,7 +146,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
)}
diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx
index a19f6e16..f4ec40f5 100644
--- a/src/app/features/albums/ShareSet.folder.test.tsx
+++ b/src/app/features/albums/ShareSet.folder.test.tsx
@@ -27,8 +27,8 @@ vi.mock('./shareController', () => ({
}));
vi.mock('./shareSet', async (importOriginal) => ({
...(await importOriginal()),
- sendShareSet: async (_port: unknown, into: string | { albumId: string }) => {
- captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}`);
+ sendShareSet: async (_port: unknown, into: string | { albumId: string; lastId: string }) => {
+ captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}@${into.lastId}`);
return { albumId: '$a', sent: 0, failed: [], failedAt: [] };
},
}));
@@ -42,8 +42,19 @@ vi.mock('react', async (importOriginal) => {
// eslint-disable-next-line import/first
import { ShareSet } from './ShareSet';
-const mount = (folder?: string[], into?: { albumId: string; lastId: string }) => {
- const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never;
+const timeline: unknown[] = [];
+const ev = (id: string, ts: number, threadRootId?: string, status: string | null = null) => ({
+ getId: () => id,
+ getTs: () => ts,
+ threadRootId,
+ status,
+});
+const mount = (folder?: string[], into?: { albumId: string; lastId: string; lastTs: number }) => {
+ const room = {
+ roomId: '!r:x',
+ hasEncryptionStateEvent: () => false,
+ getLiveTimeline: () => ({ getEvents: () => timeline }),
+ } as never;
renderToStaticMarkup(
({ event_id: '$e' }) } as never}>
@@ -61,6 +72,7 @@ describe('ShareSet: the folder reaches the album title', () => {
beforeEach(() => {
captured.run = undefined;
captured.titles.length = 0;
+ timeline.length = 0;
});
it('at the top, the typed title goes as typed', async () => {
@@ -84,7 +96,19 @@ describe('ShareSet: the folder reaches the album title', () => {
});
it('an existing album takes the files instead of a title, whatever the folder', async () => {
- mount(['a'], { albumId: '$alb', lastId: '$last' });
- expect(await send('ignored')).toBe('into:$alb');
+ mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 });
+ expect(await send('ignored')).toBe('into:$alb@$last');
+ });
+
+ it('the freshest sent event of that thread in the room timeline is where the files thread after', async () => {
+ timeline.push(
+ ev('$other', 99, '$else'),
+ ev('$local', 98, '$alb', 'sending'),
+ ev('$new', 50, '$alb'),
+ ev('$sentNotEchoed', 60, '$alb', 'sent'),
+ ev('$failed', 70, '$alb', 'not_sent')
+ );
+ mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 });
+ expect(await send('x')).toBe('into:$alb@$sentNotEchoed');
});
});
diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx
index 6ae47613..b3da7a3d 100644
--- a/src/app/features/albums/ShareSet.test.tsx
+++ b/src/app/features/albums/ShareSet.test.tsx
@@ -23,6 +23,7 @@ const view = (over: Partial> = {}, adding = false) =>
onTitle={noop}
onSend={noop}
onCancel={noop}
+ onStop={noop}
adding={adding}
/>
);
@@ -55,6 +56,7 @@ describe('the share panel, drawn', () => {
phase: { kind: 'sending', done: 2 },
});
expect(text(html)).toContain('Sending 2 of 3…');
+ expect(text(html)).toContain('Stop');
expect(text(html)).not.toContain('Send 3 files');
expect(text(html)).not.toContain('Cancel');
expect(html).toMatch(
diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx
index a8e65b4f..a7f2f783 100644
--- a/src/app/features/albums/ShareSet.tsx
+++ b/src/app/features/albums/ShareSet.tsx
@@ -1,7 +1,7 @@
import React, { useRef, useState, useSyncExternalStore } from 'react';
import { Text } from 'folds';
import { useQueryClient } from '@tanstack/react-query';
-import { Room } from 'matrix-js-sdk';
+import { EventStatus, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { uploadContent } from '../../utils/matrix';
@@ -10,7 +10,7 @@ import { getMsgContent } from '../room/msgContent';
import { FILE_HASH_KEY, hashForSend } from './fileReceipt';
import { ShareController, ShareState, createShareController } from './shareController';
import { titleIn } from './albumFolders';
-import { DEFAULT_TITLE, ExistingAlbum, sendShareSet } from './shareSet';
+import { DEFAULT_TITLE, ExistingAlbum, freshest, sendShareSet, ThreadEvent } from './shareSet';
import * as css from './AlbumView.css';
/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */
@@ -29,6 +29,7 @@ type ShareViewProps = {
onTitle: (title: string) => void;
onSend: () => void;
onCancel: () => void;
+ onStop: () => void;
/** Adding to an existing album: the files join it, so there is no title to ask for. */
adding?: boolean;
inputRef?: React.Ref;
@@ -45,6 +46,7 @@ export function ShareSetView({
onTitle,
onSend,
onCancel,
+ onStop,
adding,
inputRef,
}: ShareViewProps) {
@@ -85,9 +87,14 @@ export function ShareSetView({
{files.map((f) => f.name).join(' · ')}
{phase.kind === 'sending' ? (
-
- Sending {phase.done} of {files.length}…
-
+ <>
+
+ Sending {phase.done} of {files.length}…
+
+
+ >
) : (
<>