From e406ec7dc4b011be47794806f9166ed9abfdddc5 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:14:42 +0000 Subject: [PATCH 1/2] Files desk: Stop a send in flight; files in a replaces loop all show; Add files threads after the freshest event Stop aborts the running upload (onPromise + mx.cancelUpload) and leaves it and the rest picked as 'stopped'. foldVersions shows files caught in a replaces loop with no head instead of hiding them. An add reads the room timeline at send time for the newest event in the thread. Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- docs/file-zone.md | 2 +- src/app/features/albums/AlbumView.tsx | 4 +- .../features/albums/ShareSet.folder.test.tsx | 34 +++++-- src/app/features/albums/ShareSet.test.tsx | 2 + src/app/features/albums/ShareSet.tsx | 44 +++++++-- src/app/features/albums/buildAlbums.test.ts | 13 +-- src/app/features/albums/buildAlbums.ts | 8 +- .../features/albums/shareController.test.ts | 26 +++++- src/app/features/albums/shareController.ts | 21 +++-- src/app/features/albums/shareSet.test.ts | 89 ++++++++++++++++++- src/app/features/albums/shareSet.ts | 40 ++++++++- src/app/features/albums/versions.test.ts | 8 +- src/app/features/albums/versions.ts | 10 ++- 14 files changed, 262 insertions(+), 41 deletions(-) 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..109d538c 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,17 @@ 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') + ); + mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); + expect(await send('x')).toBe('into:$alb@$new'); }); }); 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..6bce53c7 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -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}… + + + ) : ( <>