From fa450524321da49e813efeb379001b0ad08b0fff Mon Sep 17 00:00:00 2001 From: Myx Date: Sun, 14 Jun 2026 13:05:23 +0200 Subject: [PATCH] fix: Bug - Sending files and attachment issues Hydrate playable media after disk receive, relay file-announce to sibling devices via account_sync, bind DM attachments to pre-allocated message ids, and improve gallery retry/cancel UX with bounded parallel auto-downloads. Co-authored-by: Cursor --- .../multi-device-attachment-sharing.spec.ts | 55 ++++++++++++ e2e/tests/chat/multi-image-gallery.spec.ts | 61 +++++++++++++ toju-app/src/app/domains/attachment/README.md | 6 +- .../application/facades/attachment.facade.ts | 6 ++ .../services/attachment-manager.service.ts | 32 ++++--- .../attachment-transfer.service.spec.ts | 86 ++++++++++++++++++- .../services/attachment-transfer.service.ts | 46 ++++++++-- ...ent-autodownload-concurrency.rules.spec.ts | 29 +++++++ ...tachment-autodownload-concurrency.rules.ts | 29 +++++++ .../logic/attachment-blob.rules.spec.ts | 5 +- .../logic/attachment-download.rules.spec.ts | 5 +- .../domain/logic/attachment.logic.spec.ts | 20 +++++ .../domain/logic/attachment.logic.ts | 21 +++++ .../chat-message-image-gallery.rules.spec.ts | 54 ++++++++++++ .../rules/chat-message-image-gallery.rules.ts | 39 +++++++++ .../chat-messages.component.html | 2 + .../chat-messages/chat-messages.component.ts | 28 ++++-- .../chat-message-item.component.html | 18 +++- .../chat-message-item.component.ts | 38 +++++++- .../chat-message-overlays.component.html | 74 ++++++++++++---- .../chat-message-overlays.component.ts | 36 ++++++++ .../src/app/domains/direct-message/README.md | 11 +-- .../services/direct-message.service.ts | 9 +- .../rules/dm-message-send.rules.spec.ts | 30 +++++++ .../domain/rules/dm-message-send.rules.ts | 44 ++++++++++ .../feature/dm-chat/dm-chat.component.html | 2 + .../feature/dm-chat/dm-chat.component.ts | 55 ++++++++++-- .../src/app/infrastructure/realtime/README.md | 2 +- .../account-sync/account-sync.rules.spec.ts | 12 +++ .../account-sync/account-sync.rules.ts | 1 + 30 files changed, 785 insertions(+), 71 deletions(-) create mode 100644 e2e/tests/chat/multi-image-gallery.spec.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.spec.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.ts create mode 100644 toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.spec.ts create mode 100644 toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.ts create mode 100644 toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.spec.ts create mode 100644 toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.ts diff --git a/e2e/tests/chat/multi-device-attachment-sharing.spec.ts b/e2e/tests/chat/multi-device-attachment-sharing.spec.ts index fc299df..f06b9b0 100644 --- a/e2e/tests/chat/multi-device-attachment-sharing.spec.ts +++ b/e2e/tests/chat/multi-device-attachment-sharing.spec.ts @@ -85,6 +85,61 @@ test.describe('Multi-device attachment sharing', () => { await expect(getButton.first()).toBeVisible({ timeout: 20_000 }); }); }); + + test('relays file-announce metadata to a sibling device that is already online during upload', async ({ + createClient + }) => { + const suffix = uniqueMultiDeviceName('attach-online'); + const credentials = { + username: `online_${suffix}`, + displayName: 'Multi Device User', + password: MULTI_DEVICE_PASSWORD + }; + const serverName = `Attachment Online Relay ${suffix}`; + const fileName = `${suffix}-relay.bin`; + const caption = `Uploaded while device B was online ${suffix}`; + const fileAttachment = createBinaryFilePayload(fileName, 'application/octet-stream', `relay-body-${suffix}`); + const clientA = await createClient(); + const clientB = await createClient(); + const messagesA = new ChatMessagesPage(clientA.page); + const messagesB = new ChatMessagesPage(clientB.page); + + await test.step('device A registers and creates a server', async () => { + const registerPage = new RegisterPage(clientA.page); + + await registerPage.goto(); + await registerPage.register(credentials.username, credentials.displayName, credentials.password); + await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); + + const search = new ServerSearchPage(clientA.page); + + await search.createServer(serverName, { description: 'Sibling online file-announce relay coverage' }); + await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + await messagesA.waitForReady(); + }); + + await test.step('device B logs into the same server before the upload starts', async () => { + await loginSecondDeviceIntoServer(clientB.page, credentials, serverName); + await clientA.page.bringToFront(); + await messagesA.waitForReady(); + await clientB.page.bringToFront(); + await messagesB.waitForReady(); + }); + + await test.step('device A uploads while device B is already in the room', async () => { + await clientA.page.bringToFront(); + await messagesA.attachFiles([fileAttachment]); + await messagesA.sendMessage(caption); + await expect(messagesA.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('device B learns attachment metadata without a server-rail click dance', async () => { + await clientB.page.bringToFront(); + await expect(messagesB.getMessageItemByText(caption)).toBeVisible({ timeout: 90_000 }); + await expect(messagesB.getMessageItemByText(caption).getByText(fileName, { exact: false })) + .toBeVisible({ timeout: 90_000 }); + }); + }); }); function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload { diff --git a/e2e/tests/chat/multi-image-gallery.spec.ts b/e2e/tests/chat/multi-image-gallery.spec.ts new file mode 100644 index 0000000..bfdb8cd --- /dev/null +++ b/e2e/tests/chat/multi-image-gallery.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '../../fixtures/multi-client'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; +import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page'; + +test.describe('Multi-image gallery grouping', () => { + test.describe.configure({ timeout: 180_000 }); + + test('groups three images in one message bubble with a visible grid', async ({ createClient }) => { + const suffix = uniqueName('gallery'); + const client = await createClient(); + const registerPage = new RegisterPage(client.page); + const search = new ServerSearchPage(client.page); + const messages = new ChatMessagesPage(client.page); + const serverName = `Gallery Group ${suffix}`; + const imageNames = [ + `${suffix}-one.svg`, + `${suffix}-two.svg`, + `${suffix}-three.svg` + ]; + const images = imageNames.map((name) => createSvgFilePayload(name)); + + await registerPage.goto(); + await registerPage.register(`gallery_${suffix}`, 'Gallery User', 'TestPass123!'); + await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); + + await search.createServer(serverName, { description: 'Multi-image gallery regression server' }); + await expect(client.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + await messages.waitForReady(); + + await messages.attachFiles(images); + await messages.sendPendingAttachments(); + + for (const imageName of imageNames) { + await messages.expectMessageImageLoaded(imageName); + } + + const messageId = await messages.getMessageIdContainingImage(imageNames[0]); + + expect(messageId).toBeTruthy(); + + const bubble = client.page.locator(`[data-message-id="${messageId}"]`); + + await expect(bubble.locator('img[alt$=".svg"]')).toHaveCount(3, { timeout: 20_000 }); + await expect(bubble.locator('.chat-image-grid')).toBeVisible({ timeout: 20_000 }); + }); +}); + +function uniqueName(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`; +} + +function createSvgFilePayload(name: string): ChatDropFilePayload { + const svg = ''; + + return { + name, + mimeType: 'image/svg+xml', + base64: Buffer.from(svg, 'utf8').toString('base64') + }; +} diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index 8ada65c..a1833ce 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -107,14 +107,14 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ - **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window. - **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer. -- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed media stays on `savedPath` until inline display hydration runs on demand. +- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed **images** stay on `savedPath` until inline display hydration runs on demand; completed **audio/video** immediately resolve a playable URL via `attachmentStorage.getFileUrl(savedPath)` (Electron/Capacitor) or `ensureInlineDisplayObjectUrl` in the browser. - **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure. ### Failure handling If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress. -Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download. +Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download. Outgoing `file-announce` broadcasts are also relayed to sibling devices through `account_sync` (see `infrastructure/realtime/account-sync/account-sync.rules.ts`) so a second client of the same user learns attachment metadata even when it cannot P2P to itself. ```mermaid sequenceDiagram @@ -144,7 +144,7 @@ When the user navigates to a room, the manager watches the route and decides whi The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`. -Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media. +Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media. Auto-download work fans out with bounded concurrency (`ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY`, default 3 files at a time per watched room) so multiple pending files can progress in parallel without removing the per-file chunk-ack memory safety invariant. Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player. diff --git a/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts index a9cede9..99aa959 100644 --- a/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts +++ b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts @@ -135,6 +135,12 @@ export class AttachmentFacade { return this.manager.cancelRequest(...args); } + hasPendingRequest( + ...args: Parameters + ): ReturnType { + return this.manager.hasPendingRequest(...args); + } + handleFileCancel( ...args: Parameters ): ReturnType { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts index 682253a..ee9c441 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts @@ -16,6 +16,7 @@ import { isDirectMessageAttachmentRoomId, shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic'; +import { ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY, runTasksWithBoundedConcurrency } from '../../domain/logic/attachment-autodownload-concurrency.rules'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import type { FileAnnouncePayload, @@ -153,6 +154,10 @@ export class AttachmentManagerService { return this.transfer.requestImageFromAnyPeer(messageId, attachment); } + hasPendingRequest(messageId: string, attachmentId: string): boolean { + return this.transfer.hasPendingRequest(messageId, attachmentId); + } + async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise { const restored = await this.persistence.tryRestoreAttachmentFromLocal(attachment); @@ -316,33 +321,40 @@ export class AttachmentManagerService { await this.restoreLocalAttachmentsForRoom(roomId); - if (isDirectMessageAttachmentRoomId(roomId)) { - await this.requestAutoDownloadsForRuntimeRoom(roomId); - return; - } + let messageIds: string[]; - if (this.database.isReady()) { + if (isDirectMessageAttachmentRoomId(roomId)) { + messageIds = await this.collectMessageIdsForAttachmentsInRoom(roomId); + } else if (this.database.isReady()) { const messages = await this.database.getMessages(roomId, 500, 0); for (const message of messages) { this.runtimeStore.rememberMessageRoom(message.id, message.roomId); - await this.requestAutoDownloadsForMessage(message.id); } - return; + messageIds = messages.map((message) => message.id); + } else { + messageIds = await this.collectMessageIdsForAttachmentsInRoom(roomId); } - await this.requestAutoDownloadsForRuntimeRoom(roomId); + await runTasksWithBoundedConcurrency( + messageIds.map((messageId) => () => this.requestAutoDownloadsForMessage(messageId)), + ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY + ); } - private async requestAutoDownloadsForRuntimeRoom(roomId: string): Promise { + private async collectMessageIdsForAttachmentsInRoom(roomId: string): Promise { + const messageIds: string[] = []; + for (const [messageId] of this.runtimeStore.getAttachmentEntries()) { const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId); if (attachmentRoomId === roomId) { - await this.requestAutoDownloadsForMessage(messageId); + messageIds.push(messageId); } } + + return messageIds; } private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts index 86b35ed..6764644 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts @@ -414,6 +414,7 @@ describe('AttachmentTransferService', () => { fileId: FILE_ID, index: 0 }); + expect(persistence.saveFileToDisk).not.toHaveBeenCalled(); }); @@ -480,6 +481,7 @@ describe('AttachmentTransferService', () => { fileId: FILE_ID, index: 0 }); + expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled(); expect(persistence.saveFileToDisk).not.toHaveBeenCalled(); expect(attachment.objectUrl).toBeUndefined(); @@ -510,15 +512,16 @@ describe('AttachmentTransferService', () => { fileId: FILE_ID, index: 0 }); + expect(persistence.saveFileToDisk).not.toHaveBeenCalled(); expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined(); }); - it('does not hydrate media blobs after a disk-streamed download completes', async () => { + it('does not hydrate image blobs after a disk-streamed download completes', async () => { attachmentStorage.canStreamToDisk.mockReturnValue(true); const service = createService(); - const attachment = registerIncomingVideo(3); + const attachment = registerIncomingAttachment(3); service.handleFileChunk(chunkPayload(0, 1, [ 1, @@ -530,9 +533,55 @@ describe('AttachmentTransferService', () => { expect(attachment.savedPath).toBeTruthy(); expect(attachment.objectUrl).toBeUndefined(); + expect(attachmentStorage.getFileUrl).not.toHaveBeenCalled(); expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled(); }); + it('hydrates playable media with a native file url after disk-streamed download completes', async () => { + attachmentStorage.canStreamToDisk.mockReturnValue(true); + attachmentStorage.getFileUrl.mockResolvedValue('file:///appdata/server/room/files/clip.mp4'); + + const service = createService(); + const attachment = registerIncomingVideo(3); + + service.handleFileChunk(chunkPayload(0, 1, [ + 1, + 2, + 3 + ])); + + await vi.waitFor(() => expect(attachment.objectUrl).toBe('file:///appdata/server/room/files/clip.mp4')); + + expect(attachment.available).toBe(true); + expect(attachment.savedPath).toBeTruthy(); + expect(attachmentStorage.getFileUrl).toHaveBeenCalledWith(attachment.savedPath); + expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled(); + }); + + it('falls back to inline blob hydration for playable media when no native file url exists', async () => { + attachmentStorage.canStreamToDisk.mockReturnValue(true); + attachmentStorage.getFileUrl.mockResolvedValue(null); + persistence.ensureInlineDisplayObjectUrl.mockImplementation(async (entry) => { + entry.objectUrl = 'blob:http://localhost/clip'; + entry.available = true; + return true; + }); + + const service = createService(); + const attachment = registerIncomingVideo(3); + + service.handleFileChunk(chunkPayload(0, 1, [ + 1, + 2, + 3 + ])); + + await vi.waitFor(() => expect(persistence.ensureInlineDisplayObjectUrl).toHaveBeenCalled()); + + expect(attachment.objectUrl).toBe('blob:http://localhost/clip'); + expect(attachment.available).toBe(true); + }); + it('rejects oversized browser downloads before requesting peers', async () => { attachmentStorage.canStreamToDisk.mockReturnValue(false); attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024); @@ -727,4 +776,37 @@ describe('AttachmentTransferService', () => { expect(attachment.available).toBe(true); expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe'); }); + + it('sends file-cancel to pending request peers instead of only the uploader', async () => { + const mirrorPeer = 'mirror-peer'; + + webrtc.getConnectedPeers.mockReturnValue([mirrorPeer]); + webrtc.sendToPeer.mockClear(); + + const service = createService(); + const attachment = registerIncomingAttachment(3_000); + + attachment.uploaderPeerId = 'uploader-peer'; + runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer); + runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, attachment.uploaderPeerId); + + await service.requestFromAnyPeer(MESSAGE_ID, attachment); + + expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({ + type: 'file-request' + })); + + attachment.receivedBytes = 512; + service.cancelRequest(MESSAGE_ID, attachment); + + expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, { + type: 'file-cancel', + messageId: MESSAGE_ID, + fileId: FILE_ID + }); + + expect(attachment.receivedBytes).toBe(0); + expect(attachment.available).toBe(false); + expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false); + }); }); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index 1f77afd..11080a9 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -229,6 +229,10 @@ export class AttachmentTransferService { } requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise { + if ((attachment.receivedBytes ?? 0) > 0 || this.hasPendingRequest(messageId, attachment.id)) { + this.cancelRequest(messageId, attachment); + } + return this.requestFromAnyPeer(messageId, attachment); } @@ -456,22 +460,22 @@ export class AttachmentTransferService { } cancelRequest(messageId: string, attachment: Attachment): void { - const targetPeerId = attachment.uploaderPeerId; - - if (!targetPeerId) - return; - try { + const requestKey = this.buildRequestKey(messageId, attachment.id); const assemblyKey = `${messageId}:${attachment.id}`; + const pendingPeers = this.runtimeStore.getPendingRequestPeers(requestKey); this.runtimeStore.deleteChunkBuffer(assemblyKey); this.runtimeStore.deleteChunkCount(assemblyKey); + this.runtimeStore.deletePendingRequest(requestKey); void this.deleteDiskReceiveAssembly(assemblyKey); + this.chunkAcks.cancelPendingForFile(messageId, attachment.id); attachment.receivedBytes = 0; attachment.speedBps = 0; attachment.startedAtMs = undefined; attachment.lastUpdateMs = undefined; + attachment.requestError = undefined; if (attachment.objectUrl) { try { @@ -489,8 +493,21 @@ export class AttachmentTransferService { messageId, fileId: attachment.id }; + const peersToNotify = new Set(); - this.webrtc.sendToPeer(targetPeerId, fileCancelEvent); + if (pendingPeers) { + for (const peerId of pendingPeers) { + peersToNotify.add(peerId); + } + } + + if (attachment.uploaderPeerId) { + peersToNotify.add(attachment.uploaderPeerId); + } + + for (const peerId of peersToNotify) { + this.webrtc.sendToPeer(peerId, fileCancelEvent); + } } catch { /* best-effort */ } } @@ -997,6 +1014,23 @@ export class AttachmentTransferService { this.runtimeStore.touch(); void this.persistence.persistAttachmentMeta(attachment); void this.announceLocalHost(attachment); + void this.hydratePlayableMediaAfterDiskReceive(attachment); + } + + private async hydratePlayableMediaAfterDiskReceive(attachment: Attachment): Promise { + if (!this.isPlayableMedia(attachment) || !attachment.savedPath) { + return; + } + + const nativeUrl = await this.attachmentStorage.getFileUrl(attachment.savedPath); + + if (nativeUrl) { + attachment.objectUrl = nativeUrl; + this.runtimeStore.touch(); + return; + } + + await this.persistence.ensureInlineDisplayObjectUrl(attachment); } private async getOrCreateDiskReceiveAssembly( diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.spec.ts new file mode 100644 index 0000000..bde3645 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.spec.ts @@ -0,0 +1,29 @@ +import { runTasksWithBoundedConcurrency } from './attachment-autodownload-concurrency.rules'; + +describe('attachment-autodownload-concurrency.rules', () => { + it('runs tasks with bounded concurrency', async () => { + let active = 0; + let maxActive = 0; + + const tasks = Array.from({ length: 6 }, (_, index) => async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + + return index; + }); + const results = await runTasksWithBoundedConcurrency(tasks, 2); + + expect(results).toEqual([ + 0, + 1, + 2, + 3, + 4, + 5 + ]); + + expect(maxActive).toBeLessThanOrEqual(2); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.ts new file mode 100644 index 0000000..d90fa4e --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload-concurrency.rules.ts @@ -0,0 +1,29 @@ +/** Default parallel attachment auto-download limit per watched room. */ +export const ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY = 3; + +export async function runTasksWithBoundedConcurrency( + tasks: readonly (() => Promise)[], + concurrency: number +): Promise { + if (tasks.length === 0) { + return []; + } + + const limit = Math.max(1, Math.min(concurrency, tasks.length)); + const results: T[] = new Array(tasks.length); + + let nextIndex = 0; + + async function runWorker(): Promise { + while (nextIndex < tasks.length) { + const currentIndex = nextIndex; + + nextIndex += 1; + results[currentIndex] = await tasks[currentIndex](); + } + } + + await Promise.all(Array.from({ length: limit }, () => runWorker())); + + return results; +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts index 37b9634..fd2adf2 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts @@ -4,10 +4,7 @@ import { it } from 'vitest'; -import { - base64DecodedByteLength, - decodeBase64ToUint8Array -} from './attachment-blob.rules'; +import { base64DecodedByteLength, decodeBase64ToUint8Array } from './attachment-blob.rules'; describe('attachment blob rules', () => { it('decodes base64 payloads into byte arrays', () => { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts index 40e5d14..8b6ecf9 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts @@ -4,10 +4,7 @@ import { it } from 'vitest'; -import { - canDownloadAttachment, - resolveAttachmentDiskPath -} from './attachment-download.rules'; +import { canDownloadAttachment, resolveAttachmentDiskPath } from './attachment-download.rules'; describe('attachment-download.rules', () => { it('allows download when a completed disk-only attachment has no object URL', () => { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts index 98e4e4d..26a3116 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts @@ -1,6 +1,8 @@ import { getWatchedAttachmentRoomIdFromUrl, + isAttachmentPendingMediaHydration, isDirectMessageAttachmentRoomId, + isPlayableAttachmentMedia, shouldCopyUploaderMediaToAppData, shouldCopyLargeUploaderFileToAppData, shouldStreamAttachmentReceiveToDisk, @@ -83,6 +85,24 @@ describe('attachment logic', () => { }, capabilities)).toBe(true); }); + it('identifies playable media pending hydration from disk paths', () => { + expect(isPlayableAttachmentMedia({ mime: 'video/mp4' })).toBe(true); + expect(isPlayableAttachmentMedia({ mime: 'image/png' })).toBe(false); + + expect(isAttachmentPendingMediaHydration({ + mime: 'audio/mpeg', + available: true, + savedPath: '/data/song.mp3' + })).toBe(true); + + expect(isAttachmentPendingMediaHydration({ + mime: 'audio/mpeg', + available: true, + objectUrl: 'file:///data/song.mp3', + savedPath: '/data/song.mp3' + })).toBe(false); + }); + it('receives browser-sized files in memory when disk streaming is unavailable', () => { const browserCapabilities = { canStreamToDisk: false, diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts index fb0ac09..548c9b5 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts @@ -11,6 +11,27 @@ export function isAttachmentMedia(attachment: Pick): boolean attachment.mime.startsWith('audio/'); } +export function isPlayableAttachmentMedia(attachment: Pick): boolean { + return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/'); +} + +export function isAttachmentPendingMediaHydration( + attachment: Pick< + Attachment, + 'available' | 'filePath' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath' + > +): boolean { + if (!isPlayableAttachmentMedia(attachment) || attachment.objectUrl) { + return false; + } + + if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) { + return false; + } + + return !!(attachment.savedPath?.trim() || attachment.filePath?.trim()); +} + export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean { return attachment.isImage || (isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES); diff --git a/toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.spec.ts new file mode 100644 index 0000000..9b3ec1f --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.spec.ts @@ -0,0 +1,54 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { buildChatMessageGalleryTiles } from './chat-message-image-gallery.rules'; + +describe('buildChatMessageGalleryTiles', () => { + it('marks displayable, hydrating, downloading, and retry tiles from attachment state', () => { + const tiles = buildChatMessageGalleryTiles([ + { + id: 'displayable', + filename: 'ready.png', + mime: 'image/png', + isImage: true, + available: true, + objectUrl: 'blob:http://localhost/ready' + }, + { + id: 'hydrating', + filename: 'saved.png', + mime: 'image/png', + isImage: true, + available: false, + savedPath: '/appdata/saved.png' + }, + { + id: 'partial', + filename: 'partial.png', + mime: 'image/png', + isImage: true, + available: false, + receivedBytes: 128, + size: 512 + }, + { + id: 'retry', + filename: 'failed.png', + mime: 'image/png', + isImage: true, + available: false, + size: 256 + } + ]); + + expect(tiles.map((tile) => tile.state)).toEqual([ + 'displayable', + 'hydrating', + 'downloading', + 'retry' + ]); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.ts b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.ts new file mode 100644 index 0000000..8a031cc --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-gallery.rules.ts @@ -0,0 +1,39 @@ +import { + isAttachmentPendingInlineHydration, + isInlineDisplayableImage, + type ImageAttachmentCandidate +} from '../../../attachment/domain/logic/attachment-image.rules'; + +export type ChatMessageGalleryTileState = 'displayable' | 'hydrating' | 'downloading' | 'retry'; + +export interface ChatMessageGalleryTile { + attachment: T; + state: ChatMessageGalleryTileState; +} + +export function buildChatMessageGalleryTiles( + attachments: readonly T[] +): ChatMessageGalleryTile[] { + return attachments.map((attachment) => ({ + attachment, + state: resolveChatMessageGalleryTileState(attachment) + })); +} + +export function resolveChatMessageGalleryTileState( + attachment: T +): ChatMessageGalleryTileState { + if (isInlineDisplayableImage(attachment)) { + return 'displayable'; + } + + if (isAttachmentPendingInlineHydration(attachment)) { + return 'hydrating'; + } + + if ((attachment.receivedBytes ?? 0) > 0) { + return 'downloading'; + } + + return 'retry'; +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html index 5df6c22..bfb79b6 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html @@ -96,5 +96,7 @@ (copyRequested)="copyImageToClipboard($event)" (imageOpened)="openLightbox($event)" (imageContextMenuRequested)="openImageContextMenu($event)" + (imageRetryRequested)="retryGalleryImage($event)" + (imageCancelRequested)="cancelGalleryImage($event)" /> diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index 37cfb3f..8b5bac2 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -47,6 +47,7 @@ import { ChatMessageDeleteEvent, ChatMessageEditEvent, ChatMessageEmbedRemoveEvent, + ChatMessageAttachmentEvent, ChatMessageImageContextMenuEvent, ChatMessageImageLightboxEvent, ChatMessageReactionEvent, @@ -340,14 +341,17 @@ export class ChatMessagesComponent { } openImageGallery(attachments: Attachment[]): void { - const availableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl); - - if (availableImages.length < 2) { + if (attachments.length < 2) { return; } - this.attachmentsSvc.pinDisplayBlobs(availableImages); - this.galleryAttachments.set(availableImages); + const displayableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl); + + if (displayableImages.length > 0) { + this.attachmentsSvc.pinDisplayBlobs(displayableImages); + } + + this.galleryAttachments.set(attachments); } closeImageGallery(): void { @@ -372,6 +376,20 @@ export class ChatMessagesComponent { await this.attachmentDownload.downloadToUserLocation(attachment); } + retryGalleryImage(event: ChatMessageAttachmentEvent): void { + const { messageId, attachment } = event; + + if ((attachment.receivedBytes ?? 0) > 0 || this.attachmentsSvc.hasPendingRequest(messageId, attachment.id)) { + this.attachmentsSvc.cancelRequest(messageId, attachment); + } + + void this.attachmentsSvc.requestImageFromAnyPeer(messageId, attachment); + } + + cancelGalleryImage(event: ChatMessageAttachmentEvent): void { + this.attachmentsSvc.cancelRequest(event.messageId, event.attachment); + } + async copyImageToClipboard(attachment: Attachment): Promise { this.closeImageContextMenu(); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index a5d18a3..8e14aa9 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -205,6 +205,22 @@ {{ ((gridImage.receivedBytes || 0) * 100) / gridImage.size | number: '1.0-0' }}% +
+ + +
} @else {
@@ -226,7 +242,7 @@
- @for (attachment of galleryAttachments(); track attachment.id) { - + @for (tile of galleryTiles(); track tile.attachment.id) { + @switch (tile.state) { + @case ('displayable') { + + } + @case ('hydrating') { +
+
+
+ } + @case ('downloading') { +
+
+ {{ ((tile.attachment.receivedBytes || 0) * 100) / tile.attachment.size | number: '1.0-0' }}% +
+
+ + +
+
+ } + @default { +
+ {{ tile.attachment.filename }} + +
+ } + } }
diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts index 0dd5ff7..ec51494 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts @@ -20,10 +20,12 @@ import { } from '@ng-icons/lucide'; import { Attachment } from '../../../../../attachment'; import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules'; +import { buildChatMessageGalleryTiles, type ChatMessageGalleryTile } from '../../../../domain/rules/chat-message-image-gallery.rules'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { ContextMenuComponent, ModalBackdropComponent } from '../../../../../../shared'; import { ChatLightboxState, + ChatMessageAttachmentEvent, ChatMessageImageGalleryEvent, ChatMessageImageContextMenuEvent, ChatMessageImageLightboxEvent @@ -68,6 +70,18 @@ export class ChatMessageOverlaysComponent implements OnDestroy { readonly copyRequested = output(); readonly imageOpened = output(); readonly imageContextMenuRequested = output(); + readonly imageRetryRequested = output(); + readonly imageCancelRequested = output(); + + readonly galleryTiles = computed[]>(() => { + const attachments = this.galleryAttachments(); + + if (!attachments) { + return []; + } + + return buildChatMessageGalleryTiles(attachments); + }); readonly lightboxAttachment = computed(() => { const state = this.lightboxState(); @@ -200,6 +214,28 @@ export class ChatMessageOverlaysComponent implements OnDestroy { }); } + retryGalleryImage(attachment: Attachment): void { + if (!attachment.messageId) { + return; + } + + this.imageRetryRequested.emit({ + messageId: attachment.messageId, + attachment + }); + } + + cancelGalleryImage(attachment: Attachment): void { + if (!attachment.messageId) { + return; + } + + this.imageCancelRequested.emit({ + messageId: attachment.messageId, + attachment + }); + } + closeImageContextMenu(): void { this.contextMenuClosed.emit(); } diff --git a/toju-app/src/app/domains/direct-message/README.md b/toju-app/src/app/domains/direct-message/README.md index 1778f65..b353e7c 100644 --- a/toju-app/src/app/domains/direct-message/README.md +++ b/toju-app/src/app/domains/direct-message/README.md @@ -21,11 +21,12 @@ direct-message/ ## Flow 1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`. -2. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id. -3. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued. -4. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`. -5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back. -6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event. +2. `DmChatComponent.handleMessageSubmitted` pre-allocates the outgoing message id via `planDmMessageSend` (`domain/rules/dm-message-send.rules.ts`), passes it to `sendMessage(..., id)`, and binds pending files to that **same** id with `AttachmentFacade.publishAttachments`. Never attach after `sendMessage` resolves by re-discovering the message — caption-less media races the async create path the same way server rooms used to. +3. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id. +4. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued. +5. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`. +6. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back. +7. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event. Unread counts are idempotent by message id: re-receiving or syncing a message that already exists can update status/content metadata but must not increment the conversation unread count again. diff --git a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts index 733ae29..13b2b8c 100644 --- a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts @@ -213,7 +213,12 @@ export class DirectMessageService { } } - async sendMessage(conversationId: string, content: string, replyToId?: string): Promise { + async sendMessage( + conversationId: string, + content: string, + replyToId?: string, + id?: string + ): Promise { const normalizedContent = content.trim(); if (!normalizedContent) { @@ -232,7 +237,7 @@ export class DirectMessageService { } const message: DirectMessage = { - id: uuidv4(), + id: id ?? uuidv4(), conversationId, senderId, recipientId, diff --git a/toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.spec.ts b/toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.spec.ts new file mode 100644 index 0000000..6506484 --- /dev/null +++ b/toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.spec.ts @@ -0,0 +1,30 @@ +import { planDmMessageSend } from './dm-message-send.rules'; + +function makeFile(name: string): File { + return new File(['x'], name, { type: 'image/png' }); +} + +describe('planDmMessageSend', () => { + it('binds attachments to the same pre-allocated message id', () => { + const generateId = () => 'dm-msg-1'; + const plan = planDmMessageSend({ + generateId, + content: 'hello', + pendingFiles: [makeFile('a.png'), makeFile('b.png')] + }); + + expect(plan.action.id).toBe('dm-msg-1'); + expect(plan.attachmentBinding?.messageId).toBe('dm-msg-1'); + expect(plan.attachmentBinding?.files).toHaveLength(2); + }); + + it('returns no attachment binding when there are no pending files', () => { + const plan = planDmMessageSend({ + generateId: () => 'dm-msg-2', + content: 'text only', + pendingFiles: [] + }); + + expect(plan.attachmentBinding).toBeNull(); + }); +}); diff --git a/toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.ts b/toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.ts new file mode 100644 index 0000000..f0420d7 --- /dev/null +++ b/toju-app/src/app/domains/direct-message/domain/rules/dm-message-send.rules.ts @@ -0,0 +1,44 @@ +/** + * Pure planning for an outgoing direct message and its attachments. + * + * Mirrors `planChatMessageSend`: the message id must be allocated before + * dispatch so pending files bind to the same bubble instead of racing the + * async create path and landing on a sibling message. + */ +export interface DmMessageSendAction { + id: string; + content: string; + replyToId?: string; +} + +export interface DmMessageAttachmentBinding { + messageId: string; + files: File[]; +} + +export interface DmMessageSendPlan { + action: DmMessageSendAction; + attachmentBinding: DmMessageAttachmentBinding | null; +} + +export interface DmMessageSendInput { + generateId: () => string; + content: string; + pendingFiles: File[]; + replyToId?: string; +} + +export function planDmMessageSend(input: DmMessageSendInput): DmMessageSendPlan { + const id = input.generateId(); + + return { + action: { + id, + content: input.content, + replyToId: input.replyToId + }, + attachmentBinding: input.pendingFiles.length > 0 + ? { messageId: id, files: input.pendingFiles } + : null + }; +} diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html index f94e8f6..79f684d 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html @@ -171,6 +171,8 @@ (copyRequested)="copyImageToClipboard($event)" (imageOpened)="openLightbox($event)" (imageContextMenuRequested)="openImageContextMenu($event)" + (imageRetryRequested)="retryGalleryImage($event)" + (imageCancelRequested)="cancelGalleryImage($event)" /> } @else {
{{ 'dm.chat.selectPrompt' | translate }}
diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts index 7781f5d..92c4646 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts @@ -14,6 +14,7 @@ import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { toSignal } from '@angular/core/rxjs-interop'; import { map } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { ViewportService } from '../../../../core/platform'; import { @@ -29,6 +30,7 @@ import { } from '../../../attachment'; import { ThemeNodeDirective } from '../../../theme'; import { DirectMessageService } from '../../application/services/direct-message.service'; +import { planDmMessageSend } from '../../domain/rules/dm-message-send.rules'; import { isConversationBound } from './dm-chat.rules'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules'; @@ -52,7 +54,11 @@ import { type ChatMessageEmbedRemoveEvent } from '../../../chat'; import { stepLightboxIndex } from '../../../chat/domain/rules/chat-message-lightbox.rules'; -import { ChatLightboxState, ChatMessageImageLightboxEvent } from '../../../chat/feature/chat-messages/models/chat-messages.model'; +import { + ChatLightboxState, + ChatMessageAttachmentEvent, + ChatMessageImageLightboxEvent +} from '../../../chat/feature/chat-messages/models/chat-messages.model'; import type { DirectMessageStatus, LinkMetadata, @@ -306,13 +312,32 @@ export class DmChatComponent { } const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n'); + const plan = planDmMessageSend({ + generateId: uuidv4, + content, + pendingFiles: event.pendingFiles, + replyToId: this.replyTo()?.id + }); - void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => { + void this.directMessages.sendMessage( + conversation.id, + plan.action.content, + plan.action.replyToId, + plan.action.id + ).then(() => { this.replyTo.set(null); - if (event.pendingFiles.length > 0) { - this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`); - this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined); + if (plan.attachmentBinding) { + this.attachments.rememberMessageRoom( + plan.attachmentBinding.messageId, + `direct-message:${conversation.id}` + ); + + void this.attachments.publishAttachments( + plan.attachmentBinding.messageId, + plan.attachmentBinding.files, + this.currentUserId() || undefined + ); } }); } @@ -466,13 +491,11 @@ export class DmChatComponent { } openImageGallery(attachments: Attachment[]): void { - const availableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl); - - if (availableImages.length < 2) { + if (attachments.length < 2) { return; } - this.galleryAttachments.set(availableImages); + this.galleryAttachments.set(attachments); } closeImageGallery(): void { @@ -491,6 +514,20 @@ export class DmChatComponent { await this.attachmentDownload.downloadToUserLocation(attachment); } + retryGalleryImage(event: ChatMessageAttachmentEvent): void { + const { messageId, attachment } = event; + + if ((attachment.receivedBytes ?? 0) > 0 || this.attachments.hasPendingRequest(messageId, attachment.id)) { + this.attachments.cancelRequest(messageId, attachment); + } + + void this.attachments.requestImageFromAnyPeer(messageId, attachment); + } + + cancelGalleryImage(event: ChatMessageAttachmentEvent): void { + this.attachments.cancelRequest(event.messageId, event.attachment); + } + async copyImageToClipboard(attachment: Attachment): Promise { this.closeImageContextMenu(); diff --git a/toju-app/src/app/infrastructure/realtime/README.md b/toju-app/src/app/infrastructure/realtime/README.md index 77cc1f4..6570bb4 100644 --- a/toju-app/src/app/infrastructure/realtime/README.md +++ b/toju-app/src/app/infrastructure/realtime/README.md @@ -172,7 +172,7 @@ Browsers do not reliably fire WebSocket close events during page refresh or navi Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values per tab/device). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events. -Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions, **chat message creates/revisions**) syncs through **`account_sync`** WebSocket messages. The client wraps relayable P2P broadcast events and the server forwards them to other connections for the same identity via `notifyOtherConnectionsForOderId`. When a new device identifies, existing connections receive `account_sync_peer_online` and push a full snapshot including chunked `chat-sync-batch` history for every saved room. Each `chat-sync-batch` carries its messages' attachment metadata (`attachments` map, local paths stripped) so sibling devices learn about synced attachments without holding the bytes. +Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions, **chat message creates/revisions**, **attachment `file-announce` metadata**) syncs through **`account_sync`** WebSocket messages. The client wraps relayable P2P broadcast events and the server forwards them to other connections for the same identity via `notifyOtherConnectionsForOderId`. Relayable types live in `account-sync/account-sync.rules.ts` (`RELAYABLE_ACCOUNT_SYNC_TYPES`); `file-announce` is included so sibling devices learn about new attachment metadata without requiring a cross-user mirror host first. When a new device identifies, existing connections receive `account_sync_peer_online` and push a full snapshot including chunked `chat-sync-batch` history for every saved room. Each `chat-sync-batch` carries its messages' attachment metadata (`attachments` map, local paths stripped) so sibling devices learn about synced attachments without holding the bytes. RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`. diff --git a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.spec.ts b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.spec.ts index f9db461..48abcda 100644 --- a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.spec.ts +++ b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.spec.ts @@ -14,6 +14,18 @@ describe('account-sync.rules', () => { expect(isRelayableAccountSyncEvent({ type: 'chat-message', message: {} as never })).toBe(true); expect(isRelayableAccountSyncEvent({ type: 'message-revision', revision: {} as never })).toBe(true); expect(isRelayableAccountSyncEvent({ type: 'chat-sync-batch', roomId: 'r1', messages: [] })).toBe(true); + expect(isRelayableAccountSyncEvent({ + type: 'file-announce', + messageId: 'm1', + file: { + id: 'f1', + filename: 'photo.png', + size: 1, + mime: 'image/png', + isImage: true + } + })).toBe(true); + expect(isRelayableAccountSyncEvent({ type: 'voice-state', voiceState: {} as never })).toBe(false); }); diff --git a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.ts b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.ts index 163880b..0dd44d8 100644 --- a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.ts +++ b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.ts @@ -5,6 +5,7 @@ const RELAYABLE_ACCOUNT_SYNC_TYPES = new Set([ 'chat-message', 'message-revision', 'chat-sync-batch', + 'file-announce', 'user-avatar-summary', 'user-avatar-request', 'user-avatar-full',