Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d7f22fd2 | ||
|
|
41ebaf2407 |
@@ -25,6 +25,20 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### Keep `NgOptimizedImage` off runtime blob and data URLs [angular] [images]
|
||||||
|
|
||||||
|
- **Trigger:** Angular template lint suggests replacing `[src]` with `ngSrc` for a user-uploaded image rendered from `blob:` or `data:`.
|
||||||
|
- **Rule:** Keep a plain `src` binding, document/disable `prefer-ngsrc`, and use native loading/decoding plus the app's own lifecycle controls; Angular throws `NG02952` for blob/data `ngSrc`.
|
||||||
|
- **Why:** `NgOptimizedImage` targets network/CDN images and cannot resize, preload, or safely manage renderer-created attachment blobs.
|
||||||
|
- **Example:** chat attachment thumbnails use `[src]="attachment.objectUrl" loading="lazy" decoding="async"`, never `[ngSrc]`.
|
||||||
|
|
||||||
|
### Read the exact Obsidian bug note before diagnosing a named ticket [workflow] [bugs]
|
||||||
|
|
||||||
|
- **Trigger:** The user names a `Bug - …` ticket, but the worktree already contains plausible changes or a similarly named resolved ticket.
|
||||||
|
- **Rule:** Resolve the exact note under `Log/Bugs/`, read every reported variant and reproduction step, and only then decide which code changes and status update belong to that ticket.
|
||||||
|
- **Why:** attachment reload-host changes looked related to “Images and files in chat doesn't load” but came from a separate resolved ticket and did not cover the reported channel-switch state regression.
|
||||||
|
- **Example:** read `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/Bug - Images and files in chat doesn't load.md` before implementing or committing its fix.
|
||||||
|
|
||||||
### Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file [i18n] [testing]
|
### Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file [i18n] [testing]
|
||||||
|
|
||||||
- **Trigger:** Added new `call.errors.*` keys to `toju-app/public/i18n/catalog/call.json` and used them in code; the full test run failed in `app-i18n-catalog.rules.spec.ts` with "Missing i18n keys" even though the keys existed in the catalog file.
|
- **Trigger:** Added new `call.errors.*` keys to `toju-app/public/i18n/catalog/call.json` and used them in code; the full test run failed in `app-i18n-catalog.rules.spec.ts` with "Missing i18n keys" even though the keys existed in the catalog file.
|
||||||
|
|||||||
@@ -78,13 +78,19 @@ This area does **not** own:
|
|||||||
- Transfers are between connected peers only (no server CDN).
|
- Transfers are between connected peers only (no server CDN).
|
||||||
- Receive strategy is decided once at request time by `canReceiveAttachment` (`attachment.logic.ts`): ≤ 10 MB assembles in memory everywhere; > 10 MB streams to disk on Electron/Capacitor, assembles in memory on the browser up to its 50 MB persist cap, and is rejected with a visible `fileTooLarge` error beyond that. `handleFileChunk` must accept whatever the request gate admitted — a stricter chunk-time size cap silently drops chunks and stalls the transfer.
|
- Receive strategy is decided once at request time by `canReceiveAttachment` (`attachment.logic.ts`): ≤ 10 MB assembles in memory everywhere; > 10 MB streams to disk on Electron/Capacitor, assembles in memory on the browser up to its 50 MB persist cap, and is rejected with a visible `fileTooLarge` error beyond that. `handleFileChunk` must accept whatever the request gate admitted — a stricter chunk-time size cap silently drops chunks and stalls the transfer.
|
||||||
- Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate.
|
- Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate.
|
||||||
|
- Startup hydration is an availability boundary: file requests and host re-announcements wait for persisted metadata before inspecting local files. A host re-announces persisted files after reload and on peer connection even from non-chat routes, and can recover an original Electron source path by copying it into app data on demand before serving.
|
||||||
|
- Startup database hydration merges persisted metadata into the live runtime attachment map; it must preserve attachments announced during initialization and completed runtime state (`available`, progress, display URL) while filling missing local paths. Replacing the map can regress a completed download to Retry, spinner, or 100% after navigation.
|
||||||
|
- Starting a request updates the runtime version immediately so inline cards and galleries show pending/download state at zero bytes; exhausting all candidate peers surfaces `fileNotFound` instead of silently clearing the pending request. A repeat host announce re-queues guarded auto-download recovery for eligible media.
|
||||||
- Display-blob memory invariants (added 2026-07-14, RAM investigation):
|
- Display-blob memory invariants (added 2026-07-14, RAM investigation):
|
||||||
- Inline hydration (`chat-message-item` effect) only runs for messages that are visible or within the `IntersectionObserver` root margin — gated by `attachment-hydration-visibility.rules.ts`. Off-screen rows never load blobs.
|
- Inline hydration (`chat-message-item` effect) only runs for messages that are visible or within the `IntersectionObserver` root margin — gated by `attachment-hydration-visibility.rules.ts`. Off-screen rows never load blobs.
|
||||||
|
- Visibility observation uses the rendered message row (`componentHost.firstElementChild`), not the boxless Angular component host; otherwise returning to a channel leaves revoked attachments on permanent spinners.
|
||||||
|
- Disk-to-blob hydration is deduplicated per attachment and capped at two active tasks. Offscreen/destroy lifecycle cancellation is checked after every IPC read and before object-URL assignment, so rapid channel switches cannot accumulate stale full-file buffers or orphaned blobs; pinned fullscreen/gallery attachments are exempt.
|
||||||
- Disk-hydrated blobs are **not** duplicated into `AttachmentRuntimeStore.originalFiles`; peer requests are served from the disk path (`streamRequestedFile` prefers `resolveExistingPath`). `originalFiles` only holds uploads/downloads that have no disk copy yet.
|
- Disk-hydrated blobs are **not** duplicated into `AttachmentRuntimeStore.originalFiles`; peer requests are served from the disk path (`streamRequestedFile` prefers `resolveExistingPath`). `originalFiles` only holds uploads/downloads that have no disk copy yet.
|
||||||
- `revokeAttachmentDisplayBlob` also drops the `originalFiles` entry when `savedPath` exists, so revocation actually frees the bytes.
|
- `revokeAttachmentDisplayBlob` also drops the `originalFiles` entry when `savedPath` exists, so revocation actually frees the bytes.
|
||||||
- Message rows always revoke their display blobs on destroy (pins are respected), not only when they were visible.
|
- Message rows always revoke their display blobs on destroy (pins are respected), not only when they were visible.
|
||||||
- Room switch sweeps display blobs of all other rooms (`releaseDisplayBlobsForInactiveRooms`, driven by `collectMessageIdsForInactiveRoomBlobRelease`). Messages with unknown room mapping are left alone.
|
- Room switch sweeps display blobs of all other rooms (`releaseDisplayBlobsForInactiveRooms`, driven by `collectMessageIdsForInactiveRoomBlobRelease`). Messages with unknown room mapping are left alone.
|
||||||
- "Shared from your device" badge only when bytes are local to the viewing user.
|
- "Shared from your device" badge only when bytes are local to the viewing user.
|
||||||
|
- Blob-backed chat thumbnails use plain `src` with native lazy loading and async decoding. `NgOptimizedImage` is forbidden for these URLs because Angular rejects `blob:` inputs; fullscreen images remain eager.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,6 +126,11 @@ This area does **not** own:
|
|||||||
|
|
||||||
| Date | Change |
|
| Date | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
|
| 2026-07-14 | Bounded display hydration to two deduplicated tasks, cancelled stale channel work before blob assignment, and added native lazy/async thumbnail hints |
|
||||||
|
| 2026-07-14 | Fixed channel-return hydration by observing the rendered message row instead of the boxless component host |
|
||||||
|
| 2026-07-14 | Made zero-byte requests visible, surfaced async peer-exhaustion failures, and retried eligible media when a host re-announces |
|
||||||
|
| 2026-07-14 | Preserved live attachment/download state when startup database hydration completes after realtime events |
|
||||||
|
| 2026-07-14 | Prevented reload-time `file-not-found` responses by waiting for metadata hydration, re-announcing hosts outside chat routes, and recovering persisted source paths on demand |
|
||||||
| 2026-07-14 | Blob-memory invariants: visibility-gated hydration, no `originalFiles` duplication for disk-backed blobs, revoke-on-destroy, inactive-room blob sweep |
|
| 2026-07-14 | Blob-memory invariants: visibility-gated hydration, no `originalFiles` duplication for disk-backed blobs, revoke-on-destroy, inactive-room blob sweep |
|
||||||
| 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` |
|
| 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` |
|
||||||
| 2026-07-05 | Expanded to full contract style |
|
| 2026-07-05 | Expanded to full contract style |
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
|
|||||||
|
|
||||||
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.
|
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. 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.
|
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 and re-queue the guarded auto-download path, allowing a host that returned after `file-not-found` to recover failed inline media without another navigation; pending-request and availability gates prevent duplicate transfers. 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.
|
||||||
|
|
||||||
|
After reload, serving and re-announcing wait for persisted attachment metadata to finish hydrating. Hosted files are announced even when the uploader is not currently viewing a room, so reconnecting peers can discover them from dashboard and other non-chat routes. If only an original Electron file-picker path survived, the first request copies that file into app data before streaming it; a request must not receive `file-not-found` merely because it raced startup hydration.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -192,7 +194,7 @@ Direct-message attachments use the conversation id instead of the server-room pa
|
|||||||
|
|
||||||
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
|
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
|
||||||
|
|
||||||
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On restore, `ensureInlineDisplayObjectUrl` resolves the stored path and, when the active store exposes a directly loadable URL (`providesInlineObjectUrl`, i.e. Capacitor), uses that URL as-is; otherwise it rebuilds a `Blob` from the stored bytes (Electron via chunked reads, browser via whole-file read with the correct MIME). Because the browser store persists bytes to IndexedDB, sent and received files are remembered across reload/restart on every platform.
|
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. Database hydration merges into attachments already learned or downloaded during startup instead of replacing the runtime map: live `available`, `receivedBytes`, and `objectUrl` state wins, while persisted local paths fill any missing path fields. This prevents late initialization from turning completed downloads back into Retry/spinner/100% states or dropping an announce that arrived during startup. On restore, `ensureInlineDisplayObjectUrl` resolves the stored path and, when the active store exposes a directly loadable URL (`providesInlineObjectUrl`, i.e. Capacitor), uses that URL as-is; otherwise it rebuilds a `Blob` from the stored bytes (Electron via chunked reads, browser via whole-file read with the correct MIME). Because the browser store persists bytes to IndexedDB, sent and received files are remembered across reload/restart on every platform.
|
||||||
|
|
||||||
## Runtime store
|
## Runtime store
|
||||||
|
|
||||||
@@ -210,7 +212,8 @@ Components read attachment state reactively through the store's signals. The sto
|
|||||||
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
|
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
|
||||||
|
|
||||||
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
|
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
|
||||||
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`). Hydration itself is visibility-gated (`attachment-hydration-visibility.rules.ts`) — off-screen rows never load blobs, and destroyed rows always release theirs.
|
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`). The observer targets the rendered message row rather than the potentially boxless Angular component host, so returning to a channel reliably marks visible rows for rehydration. Hydration itself is visibility-gated (`attachment-hydration-visibility.rules.ts`) — off-screen rows never load blobs, and destroyed rows always release theirs.
|
||||||
|
- **Bounded hydration:** disk-to-blob display hydration is deduplicated per `(messageId, attachmentId)` and globally limited to two active reads. Leaving/destroying an unpinned message row cancels its queued or active hydration; every IPC chunk and the final object-URL assignment re-check cancellation so stale work from rapid channel switches can never reattach orphaned blobs after teardown.
|
||||||
- **No byte duplication:** disk-hydrated blobs are never copied into `AttachmentRuntimeStore.originalFiles` (`applyAttachmentBlob`); revocation of a disk-backed blob also drops any stale `originalFiles` entry. `originalFiles` only carries uploads/downloads that have no disk copy yet.
|
- **No byte duplication:** disk-hydrated blobs are never copied into `AttachmentRuntimeStore.originalFiles` (`applyAttachmentBlob`); revocation of a disk-backed blob also drops any stale `originalFiles` entry. `originalFiles` only carries uploads/downloads that have no disk copy yet.
|
||||||
- **Room switch sweep:** `releaseDisplayBlobsForInactiveRooms` revokes display blobs for messages of all other rooms when navigation lands on a different room.
|
- **Room switch sweep:** `releaseDisplayBlobsForInactiveRooms` revokes display blobs for messages of all other rooms when navigation lands on a different room.
|
||||||
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
|
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
|
||||||
@@ -218,6 +221,8 @@ Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from
|
|||||||
|
|
||||||
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).
|
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).
|
||||||
|
|
||||||
|
Chat attachment images keep plain `[src]` bindings because Angular's `NgOptimizedImage` rejects runtime `blob:` URLs (`NG02952`). Inline, grid, and gallery thumbnails use native `loading="lazy"` and `decoding="async"`; fullscreen lightbox images remain eager.
|
||||||
|
|
||||||
## Cross-context feature docs
|
## Cross-context feature docs
|
||||||
|
|
||||||
- [`agents-docs/features/attachments.md`](../../../../../agents-docs/features/attachments.md)
|
- [`agents-docs/features/attachments.md`](../../../../../agents-docs/features/attachments.md)
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ export class AttachmentFacade {
|
|||||||
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
|
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelDisplayHydrationForMessage(
|
||||||
|
...args: Parameters<AttachmentManagerService['cancelDisplayHydrationForMessage']>
|
||||||
|
): ReturnType<AttachmentManagerService['cancelDisplayHydrationForMessage']> {
|
||||||
|
return this.manager.cancelDisplayHydrationForMessage(...args);
|
||||||
|
}
|
||||||
|
|
||||||
requestFile(
|
requestFile(
|
||||||
...args: Parameters<AttachmentManagerService['requestFile']>
|
...args: Parameters<AttachmentManagerService['requestFile']>
|
||||||
): ReturnType<AttachmentManagerService['requestFile']> {
|
): ReturnType<AttachmentManagerService['requestFile']> {
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAttachmentDisplayPinKey } from '../../domain/logic/attachment-blob-eviction.rules';
|
||||||
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
import { AttachmentManagerService } from './attachment-manager.service';
|
||||||
|
|
||||||
|
describe('AttachmentManagerService display hydration lifecycle', () => {
|
||||||
|
it('cancels and revokes unpinned attachments while preserving pinned fullscreen media', () => {
|
||||||
|
const unpinned: Attachment = {
|
||||||
|
id: 'att-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
size: 3,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
available: true,
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
objectUrl: 'blob:http://localhost/photo'
|
||||||
|
};
|
||||||
|
const pinned: Attachment = {
|
||||||
|
...unpinned,
|
||||||
|
id: 'att-2',
|
||||||
|
objectUrl: 'blob:http://localhost/pinned'
|
||||||
|
};
|
||||||
|
const persistence = {
|
||||||
|
cancelDisplayHydration: vi.fn(),
|
||||||
|
revokeAttachmentDisplayBlob: vi.fn(() => true)
|
||||||
|
};
|
||||||
|
const runtimeStore = {
|
||||||
|
getAttachmentsForMessage: vi.fn(() => [unpinned, pinned]),
|
||||||
|
touch: vi.fn()
|
||||||
|
};
|
||||||
|
const manager = Object.create(AttachmentManagerService.prototype) as AttachmentManagerService;
|
||||||
|
|
||||||
|
Reflect.set(manager, 'persistence', persistence);
|
||||||
|
Reflect.set(manager, 'runtimeStore', runtimeStore);
|
||||||
|
Reflect.set(manager, 'pinnedDisplayBlobKeys', new Set([buildAttachmentDisplayPinKey('msg-1', 'att-2')]));
|
||||||
|
|
||||||
|
manager.revokeOffscreenDisplayBlobsForMessage('msg-1');
|
||||||
|
|
||||||
|
expect(persistence.cancelDisplayHydration).toHaveBeenCalledTimes(1);
|
||||||
|
expect(persistence.cancelDisplayHydration).toHaveBeenCalledWith(unpinned);
|
||||||
|
expect(persistence.revokeAttachmentDisplayBlob).toHaveBeenCalledTimes(1);
|
||||||
|
expect(persistence.revokeAttachmentDisplayBlob).toHaveBeenCalledWith(unpinned);
|
||||||
|
expect(runtimeStore.touch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+34
-5
@@ -13,6 +13,7 @@ import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-bl
|
|||||||
import {
|
import {
|
||||||
buildAttachmentDisplayPinKey,
|
buildAttachmentDisplayPinKey,
|
||||||
collectMessageIdsForInactiveRoomBlobRelease,
|
collectMessageIdsForInactiveRoomBlobRelease,
|
||||||
|
isAttachmentDisplayPinned,
|
||||||
shouldRevokeDisplayBlobForAttachment
|
shouldRevokeDisplayBlobForAttachment
|
||||||
} from '../../domain/logic/attachment-blob-eviction.rules';
|
} from '../../domain/logic/attachment-blob-eviction.rules';
|
||||||
import {
|
import {
|
||||||
@@ -61,8 +62,11 @@ export class AttachmentManagerService {
|
|||||||
void this.persistence.initFromDatabase().then(async () => {
|
void this.persistence.initFromDatabase().then(async () => {
|
||||||
if (this.watchedRoomId) {
|
if (this.watchedRoomId) {
|
||||||
await this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
await this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||||
await this.announceHostedAttachments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announce regardless of the current route - a reloaded uploader
|
||||||
|
// sitting on the dashboard still hosts its persisted files.
|
||||||
|
await this.announceHostedAttachments();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -88,12 +92,19 @@ export class AttachmentManagerService {
|
|||||||
|
|
||||||
this.webrtc.onPeerConnected.subscribe(() => {
|
this.webrtc.onPeerConnected.subscribe(() => {
|
||||||
if (this.watchedRoomId) {
|
if (this.watchedRoomId) {
|
||||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId).then(async () => {
|
const watchedRoomId = this.watchedRoomId;
|
||||||
|
|
||||||
|
void this.restoreLocalAttachmentsForRoom(watchedRoomId).then(async () => {
|
||||||
await this.announceHostedAttachments();
|
await this.announceHostedAttachments();
|
||||||
});
|
});
|
||||||
|
|
||||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
void this.requestAutoDownloadsForRoom(watchedRoomId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No room open (e.g. reloaded onto the dashboard) - still announce
|
||||||
|
// persisted files so peers relearn this device hosts them.
|
||||||
|
void this.announceHostedAttachments();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +215,8 @@ export class AttachmentManagerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.cancelDisplayHydrationForMessage(messageId);
|
||||||
|
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||||
@@ -221,6 +234,20 @@ export class AttachmentManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelDisplayHydrationForMessage(messageId: string): void {
|
||||||
|
if (!messageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||||
|
if (isAttachmentDisplayPinned(messageId, attachment.id, this.pinnedDisplayBlobKeys)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistence.cancelDisplayHydration(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
releaseDisplayBlobsForInactiveRooms(activeRoomId: string | null): void {
|
releaseDisplayBlobsForInactiveRooms(activeRoomId: string | null): void {
|
||||||
const messageIds = collectMessageIdsForInactiveRoomBlobRelease(
|
const messageIds = collectMessageIdsForInactiveRoomBlobRelease(
|
||||||
Array.from(this.runtimeStore.getAttachmentEntries(), ([messageId]) => messageId),
|
Array.from(this.runtimeStore.getAttachmentEntries(), ([messageId]) => messageId),
|
||||||
@@ -246,9 +273,11 @@ export class AttachmentManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||||
const isNew = this.transfer.handleFileAnnounce(payload);
|
this.transfer.handleFileAnnounce(payload);
|
||||||
|
|
||||||
if (isNew && payload.messageId && payload.file?.id) {
|
if (payload.messageId && payload.file?.id) {
|
||||||
|
// Re-announces are recovery signals too: a host may have come back after
|
||||||
|
// an earlier file-not-found, so re-run the guarded auto-download path.
|
||||||
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
|
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+202
-1
@@ -98,6 +98,58 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
expect(attachmentStorage.getFileSize).not.toHaveBeenCalled();
|
expect(attachmentStorage.getFileSize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves a completed runtime download when database hydration finishes later', async () => {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
AttachmentPersistenceService,
|
||||||
|
AttachmentRuntimeStore,
|
||||||
|
{ provide: DatabaseService, useValue: database },
|
||||||
|
{ provide: AttachmentStorageService, useValue: attachmentStorage },
|
||||||
|
{ provide: Store, useValue: { select: () => of('room-1') } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
|
||||||
|
const runtimeStore = injector.get(AttachmentRuntimeStore);
|
||||||
|
const completedDownload = {
|
||||||
|
id: 'att-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
size: 1_500_000,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
available: true,
|
||||||
|
objectUrl: 'blob:http://localhost/completed',
|
||||||
|
receivedBytes: 1_500_000
|
||||||
|
};
|
||||||
|
const announcedDuringStartup = {
|
||||||
|
id: 'att-live',
|
||||||
|
messageId: 'msg-live',
|
||||||
|
filename: 'new-photo.png',
|
||||||
|
size: 512,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
available: false,
|
||||||
|
receivedBytes: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
runtimeStore.setAttachmentsForMessage('msg-1', [completedDownload]);
|
||||||
|
runtimeStore.setAttachmentsForMessage('msg-live', [announcedDuringStartup]);
|
||||||
|
|
||||||
|
await service.initFromDatabase();
|
||||||
|
|
||||||
|
const restored = runtimeStore.getAttachmentsForMessage('msg-1')[0];
|
||||||
|
|
||||||
|
expect(restored).toBe(completedDownload);
|
||||||
|
expect(restored).toMatchObject({
|
||||||
|
available: true,
|
||||||
|
objectUrl: 'blob:http://localhost/completed',
|
||||||
|
receivedBytes: 1_500_000,
|
||||||
|
savedPath: '/appdata/photo.png'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimeStore.getAttachmentsForMessage('msg-live')[0]).toBe(announcedDuringStartup);
|
||||||
|
});
|
||||||
|
|
||||||
it('hydrates blob URLs on demand for a single attachment', async () => {
|
it('hydrates blob URLs on demand for a single attachment', async () => {
|
||||||
const injector = Injector.create({
|
const injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -134,6 +186,144 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates concurrent display hydration for the same attachment', async () => {
|
||||||
|
attachmentStorage.canReadFileChunks.mockReturnValue(false);
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = {
|
||||||
|
id: 'att-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
size: 3,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
const [first, second] = await Promise.all([service.ensureInlineDisplayObjectUrl(attachment), service.ensureInlineDisplayObjectUrl(attachment)]);
|
||||||
|
|
||||||
|
expect(first).toBe(true);
|
||||||
|
expect(second).toBe(true);
|
||||||
|
expect(attachmentStorage.readFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits concurrent display hydration to two attachments', async () => {
|
||||||
|
attachmentStorage.canReadFileChunks.mockReturnValue(false);
|
||||||
|
|
||||||
|
const pendingReads: ((base64: string) => void)[] = [];
|
||||||
|
|
||||||
|
attachmentStorage.readFile.mockImplementation(() => new Promise<string>((resolve) => {
|
||||||
|
pendingReads.push(resolve);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachments = Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
id: `att-${index + 1}`,
|
||||||
|
messageId: `msg-${index + 1}`,
|
||||||
|
filename: `photo-${index + 1}.png`,
|
||||||
|
size: 3,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
savedPath: `/appdata/photo-${index + 1}.png`,
|
||||||
|
available: false
|
||||||
|
}));
|
||||||
|
const hydrations = attachments.map((attachment) => service.ensureInlineDisplayObjectUrl(attachment));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(attachmentStorage.readFile).toHaveBeenCalledTimes(2));
|
||||||
|
expect(pendingReads).toHaveLength(2);
|
||||||
|
|
||||||
|
pendingReads.shift()?.('QUJD');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(attachmentStorage.readFile).toHaveBeenCalledTimes(3));
|
||||||
|
|
||||||
|
for (const resolve of pendingReads) {
|
||||||
|
resolve('QUJD');
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(Promise.all(hydrations)).resolves.toEqual([
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels an in-flight hydration before it can attach a stale blob URL', async () => {
|
||||||
|
let finishChunkRead!: (base64: string) => void;
|
||||||
|
|
||||||
|
attachmentStorage.getFileSize.mockResolvedValue(3);
|
||||||
|
attachmentStorage.readFileChunk.mockImplementation(() => new Promise<string>((resolve) => {
|
||||||
|
finishChunkRead = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = {
|
||||||
|
id: 'att-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
size: 3,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
const createObjectUrlSpy = vi.spyOn(URL, 'createObjectURL');
|
||||||
|
const hydration = service.ensureInlineDisplayObjectUrl(attachment);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(attachmentStorage.readFileChunk).toHaveBeenCalledTimes(1));
|
||||||
|
service.cancelDisplayHydration(attachment);
|
||||||
|
finishChunkRead('QUJD');
|
||||||
|
|
||||||
|
await expect(hydration).resolves.toBe(false);
|
||||||
|
expect(attachment.objectUrl).toBeUndefined();
|
||||||
|
expect(createObjectUrlSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createObjectUrlSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a newer hydration to supersede cancelled stale work', async () => {
|
||||||
|
attachmentStorage.canReadFileChunks.mockReturnValue(false);
|
||||||
|
|
||||||
|
let finishStaleRead!: (base64: string) => void;
|
||||||
|
let readCount = 0;
|
||||||
|
|
||||||
|
attachmentStorage.readFile.mockImplementation(() => {
|
||||||
|
readCount++;
|
||||||
|
|
||||||
|
if (readCount === 1) {
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
finishStaleRead = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve('REVG');
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = {
|
||||||
|
id: 'att-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
size: 3,
|
||||||
|
mime: 'image/png',
|
||||||
|
isImage: true,
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
const staleHydration = service.ensureInlineDisplayObjectUrl(attachment);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(attachmentStorage.readFile).toHaveBeenCalledTimes(1));
|
||||||
|
service.cancelDisplayHydration(attachment);
|
||||||
|
|
||||||
|
const currentHydration = service.ensureInlineDisplayObjectUrl(attachment);
|
||||||
|
|
||||||
|
await expect(currentHydration).resolves.toBe(true);
|
||||||
|
finishStaleRead('QUJD');
|
||||||
|
|
||||||
|
await expect(staleHydration).resolves.toBe(false);
|
||||||
|
expect(attachmentStorage.readFile).toHaveBeenCalledTimes(2);
|
||||||
|
expect(attachment.objectUrl).toMatch(/^blob:/);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not duplicate disk-hydrated bytes into the original-file cache', async () => {
|
it('does not duplicate disk-hydrated bytes into the original-file cache', async () => {
|
||||||
const injector = Injector.create({
|
const injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -281,13 +471,24 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
isImage: true,
|
isImage: true,
|
||||||
savedPath: '/appdata/photo.png',
|
savedPath: '/appdata/photo.png',
|
||||||
available: true,
|
available: true,
|
||||||
objectUrl: 'blob:http://localhost/abc'
|
objectUrl: 'blob:http://localhost/abc',
|
||||||
|
receivedBytes: 3,
|
||||||
|
speedBps: 512,
|
||||||
|
startedAtMs: 100,
|
||||||
|
lastUpdateMs: 200
|
||||||
};
|
};
|
||||||
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||||
|
|
||||||
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
|
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
|
||||||
expect(attachment.objectUrl).toBeUndefined();
|
expect(attachment.objectUrl).toBeUndefined();
|
||||||
expect(attachment.savedPath).toBe('/appdata/photo.png');
|
expect(attachment.savedPath).toBe('/appdata/photo.png');
|
||||||
|
expect(attachment).toMatchObject({
|
||||||
|
receivedBytes: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
startedAtMs: undefined,
|
||||||
|
lastUpdateMs: undefined
|
||||||
|
});
|
||||||
|
|
||||||
expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc');
|
expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc');
|
||||||
|
|
||||||
revokeSpy.mockRestore();
|
revokeSpy.mockRestore();
|
||||||
|
|||||||
+172
-11
@@ -17,9 +17,19 @@ import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persist
|
|||||||
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
|
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
|
||||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_DISPLAY_HYDRATIONS = 2;
|
||||||
|
|
||||||
|
interface DisplayHydrationTask {
|
||||||
|
cancelled: boolean;
|
||||||
|
promise: Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentPersistenceService {
|
export class AttachmentPersistenceService {
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private activeDisplayHydrations = 0;
|
||||||
|
private readonly displayHydrationQueue: (() => void)[] = [];
|
||||||
|
private readonly displayHydrations = new Map<string, DisplayHydrationTask>();
|
||||||
|
|
||||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||||
private readonly ngrxStore = inject(Store);
|
private readonly ngrxStore = inject(Store);
|
||||||
@@ -33,6 +43,8 @@ export class AttachmentPersistenceService {
|
|||||||
const savedPathsToDelete = new Set<string>();
|
const savedPathsToDelete = new Set<string>();
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
this.cancelDisplayHydration(attachment);
|
||||||
|
|
||||||
if (attachment.objectUrl) {
|
if (attachment.objectUrl) {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(attachment.objectUrl);
|
URL.revokeObjectURL(attachment.objectUrl);
|
||||||
@@ -133,13 +145,34 @@ export class AttachmentPersistenceService {
|
|||||||
return this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: false });
|
return this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelDisplayHydration(attachment: Pick<Attachment, 'id' | 'messageId'>): void {
|
||||||
|
const hydrationKey = this.buildDisplayHydrationKey(attachment);
|
||||||
|
const task = this.displayHydrations.get(hydrationKey);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.cancelled = true;
|
||||||
|
|
||||||
|
if (this.displayHydrations.get(hydrationKey) === task) {
|
||||||
|
this.displayHydrations.delete(hydrationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revokeAttachmentDisplayBlob(attachment: Attachment): boolean {
|
revokeAttachmentDisplayBlob(attachment: Attachment): boolean {
|
||||||
|
this.cancelDisplayHydration(attachment);
|
||||||
|
|
||||||
if (!canRevokeAttachmentDisplayBlob(attachment)) {
|
if (!canRevokeAttachmentDisplayBlob(attachment)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.revokeAttachmentObjectUrl(attachment);
|
this.revokeAttachmentObjectUrl(attachment);
|
||||||
attachment.objectUrl = undefined;
|
attachment.objectUrl = undefined;
|
||||||
|
attachment.receivedBytes = 0;
|
||||||
|
attachment.speedBps = 0;
|
||||||
|
attachment.startedAtMs = undefined;
|
||||||
|
attachment.lastUpdateMs = undefined;
|
||||||
|
|
||||||
// Once the bytes live on disk, the cached File duplicate is redundant:
|
// Once the bytes live on disk, the cached File duplicate is redundant:
|
||||||
// peer requests are served from the disk path and re-display rehydrates
|
// peer requests are served from the disk path and re-display rehydrates
|
||||||
@@ -199,19 +232,64 @@ export class AttachmentPersistenceService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
|
ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
|
||||||
if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
|
if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
|
||||||
return true;
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrationKey = this.buildDisplayHydrationKey(attachment);
|
||||||
|
const existingTask = this.displayHydrations.get(hydrationKey);
|
||||||
|
|
||||||
|
if (existingTask) {
|
||||||
|
return existingTask.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task: DisplayHydrationTask = {
|
||||||
|
cancelled: false,
|
||||||
|
promise: Promise.resolve(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.displayHydrations.set(hydrationKey, task);
|
||||||
|
|
||||||
|
const scheduled = this.scheduleDisplayHydration(
|
||||||
|
task,
|
||||||
|
() => this.runInlineDisplayHydration(attachment, hydrationKey, task)
|
||||||
|
);
|
||||||
|
|
||||||
|
task.promise = scheduled.finally(() => {
|
||||||
|
if (this.displayHydrations.get(hydrationKey) === task) {
|
||||||
|
this.displayHydrations.delete(hydrationKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return task.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runInlineDisplayHydration(
|
||||||
|
attachment: Attachment,
|
||||||
|
hydrationKey: string,
|
||||||
|
task: DisplayHydrationTask
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let diskPath = await this.attachmentStorage.resolveExistingPath(attachment);
|
let diskPath = await this.attachmentStorage.resolveExistingPath(attachment);
|
||||||
|
|
||||||
|
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!diskPath) {
|
if (!diskPath) {
|
||||||
const roomName = await this.resolveStorageContainerName(attachment);
|
const roomName = await this.resolveStorageContainerName(attachment);
|
||||||
|
|
||||||
|
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName);
|
diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName);
|
||||||
|
|
||||||
if (diskPath) {
|
if (diskPath && this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
attachment.savedPath = diskPath;
|
attachment.savedPath = diskPath;
|
||||||
void this.persistAttachmentMeta(attachment);
|
void this.persistAttachmentMeta(attachment);
|
||||||
}
|
}
|
||||||
@@ -224,7 +302,7 @@ export class AttachmentPersistenceService {
|
|||||||
if (this.attachmentStorage.providesInlineObjectUrl()) {
|
if (this.attachmentStorage.providesInlineObjectUrl()) {
|
||||||
const nativeUrl = await this.attachmentStorage.getFileUrl(diskPath);
|
const nativeUrl = await this.attachmentStorage.getFileUrl(diskPath);
|
||||||
|
|
||||||
if (nativeUrl) {
|
if (nativeUrl && this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
this.revokeAttachmentObjectUrl(attachment);
|
this.revokeAttachmentObjectUrl(attachment);
|
||||||
attachment.objectUrl = nativeUrl;
|
attachment.objectUrl = nativeUrl;
|
||||||
attachment.available = true;
|
attachment.available = true;
|
||||||
@@ -233,9 +311,18 @@ export class AttachmentPersistenceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
this.revokeAttachmentObjectUrl(attachment);
|
this.revokeAttachmentObjectUrl(attachment);
|
||||||
|
|
||||||
const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath);
|
const restored = await this.restoreAttachmentBlobFromDiskPath(
|
||||||
|
attachment,
|
||||||
|
diskPath,
|
||||||
|
hydrationKey,
|
||||||
|
task
|
||||||
|
);
|
||||||
|
|
||||||
return restored;
|
return restored;
|
||||||
}
|
}
|
||||||
@@ -302,14 +389,29 @@ export class AttachmentPersistenceService {
|
|||||||
private async loadFromDatabase(): Promise<void> {
|
private async loadFromDatabase(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
||||||
const grouped = new Map<string, Attachment[]>();
|
const grouped = new Map<string, Attachment[]>(
|
||||||
|
Array.from(
|
||||||
|
this.runtimeStore.getAttachmentEntries(),
|
||||||
|
([messageId, attachments]) => [messageId, [...attachments]]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
for (const record of allRecords) {
|
for (const record of allRecords) {
|
||||||
|
const bucket = grouped.get(record.messageId) ?? [];
|
||||||
|
const runtimeAttachment = bucket.find((attachment) => attachment.id === record.id);
|
||||||
|
|
||||||
|
if (runtimeAttachment) {
|
||||||
|
const localPaths = mergeAttachmentLocalPaths(runtimeAttachment, record);
|
||||||
|
|
||||||
|
runtimeAttachment.filePath = localPaths.filePath ?? undefined;
|
||||||
|
runtimeAttachment.savedPath = localPaths.savedPath ?? undefined;
|
||||||
|
} else {
|
||||||
const attachment: Attachment = { ...record,
|
const attachment: Attachment = { ...record,
|
||||||
available: false };
|
available: false };
|
||||||
const bucket = grouped.get(record.messageId) ?? [];
|
|
||||||
|
|
||||||
bucket.push(attachment);
|
bucket.push(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
grouped.set(record.messageId, bucket);
|
grouped.set(record.messageId, bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,11 +452,16 @@ export class AttachmentPersistenceService {
|
|||||||
await this.migrateFromLocalStorage();
|
await this.migrateFromLocalStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async restoreAttachmentBlobFromDiskPath(attachment: Attachment, diskPath: string): Promise<boolean> {
|
private async restoreAttachmentBlobFromDiskPath(
|
||||||
|
attachment: Attachment,
|
||||||
|
diskPath: string,
|
||||||
|
hydrationKey: string,
|
||||||
|
task: DisplayHydrationTask
|
||||||
|
): Promise<boolean> {
|
||||||
if (this.attachmentStorage.canReadFileChunks()) {
|
if (this.attachmentStorage.canReadFileChunks()) {
|
||||||
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
|
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
|
||||||
|
|
||||||
if (!fileSize || fileSize < 1) {
|
if (!fileSize || fileSize < 1 || !this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +471,7 @@ export class AttachmentPersistenceService {
|
|||||||
const end = Math.min(start + ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, fileSize);
|
const end = Math.min(start + ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, fileSize);
|
||||||
const chunkBase64 = await this.attachmentStorage.readFileChunk(diskPath, start, end);
|
const chunkBase64 = await this.attachmentStorage.readFileChunk(diskPath, start, end);
|
||||||
|
|
||||||
if (!chunkBase64) {
|
if (!chunkBase64 || !this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,18 +482,26 @@ export class AttachmentPersistenceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
this.applyAttachmentBlob(attachment, new Blob(blobParts as BlobPart[], { type: attachment.mime }));
|
this.applyAttachmentBlob(attachment, new Blob(blobParts as BlobPart[], { type: attachment.mime }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = await this.attachmentStorage.readFile(diskPath);
|
const base64 = await this.attachmentStorage.readFile(diskPath);
|
||||||
|
|
||||||
if (!base64) {
|
if (!base64 || !this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = decodeBase64ToUint8Array(base64);
|
const bytes = decodeBase64ToUint8Array(base64);
|
||||||
|
|
||||||
|
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
this.applyAttachmentBlob(
|
this.applyAttachmentBlob(
|
||||||
attachment,
|
attachment,
|
||||||
new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime })
|
new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime })
|
||||||
@@ -395,6 +510,52 @@ export class AttachmentPersistenceService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleDisplayHydration(
|
||||||
|
task: DisplayHydrationTask,
|
||||||
|
hydrate: () => Promise<boolean>
|
||||||
|
): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve, reject) => {
|
||||||
|
this.displayHydrationQueue.push(() => {
|
||||||
|
if (task.cancelled) {
|
||||||
|
resolve(false);
|
||||||
|
this.drainDisplayHydrationQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeDisplayHydrations++;
|
||||||
|
|
||||||
|
void hydrate()
|
||||||
|
.then(resolve, reject)
|
||||||
|
.finally(() => {
|
||||||
|
this.activeDisplayHydrations--;
|
||||||
|
this.drainDisplayHydrationQueue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.drainDisplayHydrationQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private drainDisplayHydrationQueue(): void {
|
||||||
|
while (
|
||||||
|
this.activeDisplayHydrations < MAX_CONCURRENT_DISPLAY_HYDRATIONS &&
|
||||||
|
this.displayHydrationQueue.length > 0
|
||||||
|
) {
|
||||||
|
this.displayHydrationQueue.shift()?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDisplayHydrationCurrent(
|
||||||
|
hydrationKey: string,
|
||||||
|
task: DisplayHydrationTask
|
||||||
|
): boolean {
|
||||||
|
return !task.cancelled && this.displayHydrations.get(hydrationKey) === task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDisplayHydrationKey(attachment: Pick<Attachment, 'id' | 'messageId'>): string {
|
||||||
|
return `${attachment.messageId}:${attachment.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
// The blob always comes from a disk path here, so peers are served from
|
// The blob always comes from a disk path here, so peers are served from
|
||||||
// disk and no original-file copy is cached; caching one would keep a second
|
// disk and no original-file copy is cached; caching one would keep a second
|
||||||
// full copy of the bytes alive for the whole session.
|
// full copy of the bytes alive for the whole session.
|
||||||
|
|||||||
+127
-1
@@ -42,6 +42,7 @@ describe('AttachmentTransferService', () => {
|
|||||||
resolveCurrentRoomName: ReturnType<typeof vi.fn>;
|
resolveCurrentRoomName: ReturnType<typeof vi.fn>;
|
||||||
resolveStorageContainerName: ReturnType<typeof vi.fn>;
|
resolveStorageContainerName: ReturnType<typeof vi.fn>;
|
||||||
ensureInlineDisplayObjectUrl: ReturnType<typeof vi.fn>;
|
ensureInlineDisplayObjectUrl: ReturnType<typeof vi.fn>;
|
||||||
|
ensurePersistedUploadHost: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let attachmentStorage: {
|
let attachmentStorage: {
|
||||||
canWriteFiles: ReturnType<typeof vi.fn>;
|
canWriteFiles: ReturnType<typeof vi.fn>;
|
||||||
@@ -83,7 +84,8 @@ describe('AttachmentTransferService', () => {
|
|||||||
persistUploadCopyFromSourcePath: vi.fn(async () => null),
|
persistUploadCopyFromSourcePath: vi.fn(async () => null),
|
||||||
resolveCurrentRoomName: vi.fn(async () => null),
|
resolveCurrentRoomName: vi.fn(async () => null),
|
||||||
resolveStorageContainerName: vi.fn(async () => 'room'),
|
resolveStorageContainerName: vi.fn(async () => 'room'),
|
||||||
ensureInlineDisplayObjectUrl: vi.fn(async () => true)
|
ensureInlineDisplayObjectUrl: vi.fn(async () => true),
|
||||||
|
ensurePersistedUploadHost: vi.fn(async () => false)
|
||||||
};
|
};
|
||||||
|
|
||||||
attachmentStorage = {
|
attachmentStorage = {
|
||||||
@@ -741,6 +743,93 @@ describe('AttachmentTransferService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates persisted metadata before serving a file request after reload', async () => {
|
||||||
|
// Reload race: the peer's file-request arrives before initFromDatabase has
|
||||||
|
// filled the runtime store. Serving must wait for hydration instead of
|
||||||
|
// replying file-not-found for a file that is on disk.
|
||||||
|
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||||
|
attachmentStorage.getFileSize.mockResolvedValue(12 * 1024 * 1024);
|
||||||
|
persistence.whenReady.mockImplementation(async () => {
|
||||||
|
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||||
|
|
||||||
|
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.handleFileRequest({
|
||||||
|
messageId: MESSAGE_ID,
|
||||||
|
fileId: FILE_ID,
|
||||||
|
fromPeerId: 'peer-2'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalledWith(
|
||||||
|
'peer-2',
|
||||||
|
MESSAGE_ID,
|
||||||
|
FILE_ID,
|
||||||
|
'/appdata/server/room/files/setup.exe',
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(webrtc.sendToPeer).not.toHaveBeenCalledWith('peer-2', expect.objectContaining({ type: 'file-not-found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies an external upload into app data when serving a request after reload', async () => {
|
||||||
|
// savedPath missing (publish copy failed or pre-fix upload) but the original
|
||||||
|
// file-picker path survived - the serve path must persist it on demand
|
||||||
|
// instead of replying file-not-found.
|
||||||
|
attachmentStorage.resolveExistingPath
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||||
|
|
||||||
|
attachmentStorage.getFileSize.mockResolvedValue(628 * 1024 * 1024);
|
||||||
|
persistence.ensurePersistedUploadHost.mockImplementation(async (attachment: Attachment) => {
|
||||||
|
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = registerIncomingGenericFile(628 * 1024 * 1024);
|
||||||
|
|
||||||
|
attachment.filePath = '/home/nim/Downloads/setup.exe';
|
||||||
|
|
||||||
|
await service.handleFileRequest({
|
||||||
|
messageId: MESSAGE_ID,
|
||||||
|
fileId: FILE_ID,
|
||||||
|
fromPeerId: 'peer-2'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persistence.ensurePersistedUploadHost).toHaveBeenCalledWith(attachment, { hydrateMediaForDisplay: false });
|
||||||
|
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalledWith(
|
||||||
|
'peer-2',
|
||||||
|
MESSAGE_ID,
|
||||||
|
FILE_ID,
|
||||||
|
'/appdata/server/room/files/setup.exe',
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(webrtc.sendToPeer).not.toHaveBeenCalledWith('peer-2', expect.objectContaining({ type: 'file-not-found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates persisted metadata before re-announcing hosted attachments', async () => {
|
||||||
|
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||||
|
persistence.whenReady.mockImplementation(async () => {
|
||||||
|
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||||
|
|
||||||
|
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.reannounceHostedAttachments(PEER_ID);
|
||||||
|
|
||||||
|
expect(webrtc.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'file-announce',
|
||||||
|
messageId: MESSAGE_ID,
|
||||||
|
file: expect.objectContaining({ id: FILE_ID })
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('re-announces hosted attachments that can still be served from disk', async () => {
|
it('re-announces hosted attachments that can still be served from disk', async () => {
|
||||||
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||||
|
|
||||||
@@ -897,6 +986,43 @@ describe('AttachmentTransferService', () => {
|
|||||||
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false);
|
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('notifies attachment views when an outbound request starts', async () => {
|
||||||
|
const service = createService();
|
||||||
|
const attachment = registerIncomingAttachment(3_000);
|
||||||
|
const versionBeforeRequest = runtimeStore.updated();
|
||||||
|
|
||||||
|
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||||
|
|
||||||
|
expect(runtimeStore.updated()).toBeGreaterThan(versionBeforeRequest);
|
||||||
|
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true);
|
||||||
|
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, expect.objectContaining({
|
||||||
|
type: 'file-request'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a retry error when an async request race exhausts every peer', async () => {
|
||||||
|
let finishLocalRestore!: (restored: boolean) => void;
|
||||||
|
|
||||||
|
persistence.tryRestoreAttachmentFromLocal.mockImplementation(() => new Promise<boolean>((resolve) => {
|
||||||
|
finishLocalRestore = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment = registerIncomingAttachment(3_000);
|
||||||
|
const request = service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||||
|
|
||||||
|
service.handleFileNotFound({
|
||||||
|
messageId: MESSAGE_ID,
|
||||||
|
fileId: FILE_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
finishLocalRestore(false);
|
||||||
|
await request;
|
||||||
|
|
||||||
|
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false);
|
||||||
|
expect(attachment.requestError).toBe('attachment.errors.fileNotFound');
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes generic octet-stream announces into image metadata for gallery grouping', () => {
|
it('normalizes generic octet-stream announces into image metadata for gallery grouping', () => {
|
||||||
const service = createService();
|
const service = createService();
|
||||||
|
|
||||||
|
|||||||
+32
-5
@@ -169,12 +169,14 @@ export class AttachmentTransferService {
|
|||||||
|
|
||||||
async requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
async requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||||
const requestKey = this.buildRequestKey(messageId, attachment.id);
|
const requestKey = this.buildRequestKey(messageId, attachment.id);
|
||||||
const clearedRequestError = this.clearAttachmentRequestError(attachment);
|
|
||||||
|
this.clearAttachmentRequestError(attachment);
|
||||||
|
|
||||||
// Mark the request pending synchronously so concurrent triggers (file-announce,
|
// Mark the request pending synchronously so concurrent triggers (file-announce,
|
||||||
// message sync, peer connect) cannot double-request the same file - a duplicate
|
// message sync, peer connect) cannot double-request the same file - a duplicate
|
||||||
// request makes the sender stream the file twice and corrupts byte accounting.
|
// request makes the sender stream the file twice and corrupts byte accounting.
|
||||||
this.runtimeStore.setPendingRequestPeers(requestKey, new Set<string>());
|
this.runtimeStore.setPendingRequestPeers(requestKey, new Set<string>());
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
|
||||||
if (needsAttachmentDisplayHydration(attachment)) {
|
if (needsAttachmentDisplayHydration(attachment)) {
|
||||||
const hydratedLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
|
const hydratedLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
|
||||||
@@ -222,10 +224,12 @@ export class AttachmentTransferService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clearedRequestError)
|
const didSendRequest = this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
|
||||||
this.runtimeStore.touch();
|
|
||||||
|
|
||||||
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
|
if (!didSendRequest) {
|
||||||
|
attachment.requestError = this.appI18n.instant(FILE_NOT_FOUND_REQUEST_ERROR_KEY);
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFileNotFound(payload: FileNotFoundPayload): void {
|
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||||
@@ -587,13 +591,32 @@ export class AttachmentTransferService {
|
|||||||
fileId: string,
|
fileId: string,
|
||||||
fromPeerId: string
|
fromPeerId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// A request can race ahead of post-reload metadata hydration; answering
|
||||||
|
// file-not-found for a file that is on disk makes peers give up for good.
|
||||||
|
await this.persistence.whenReady();
|
||||||
|
|
||||||
const exactKey = `${messageId}:${fileId}`;
|
const exactKey = `${messageId}:${fileId}`;
|
||||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
const attachment = list.find((entry) => entry.id === fileId);
|
const attachment = list.find((entry) => entry.id === fileId);
|
||||||
const diskPath = attachment
|
|
||||||
|
let diskPath = attachment
|
||||||
? await this.attachmentStorage.resolveExistingPath(attachment)
|
? await this.attachmentStorage.resolveExistingPath(attachment)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (!diskPath && attachment && attachment.filePath?.trim()) {
|
||||||
|
// Only the original file-picker path survived the reload (publish copy
|
||||||
|
// failed or predates it). Copy it into app data now so this and future
|
||||||
|
// requests are served from disk instead of failing.
|
||||||
|
const persisted = await this.persistence.ensurePersistedUploadHost(
|
||||||
|
attachment,
|
||||||
|
{ hydrateMediaForDisplay: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (persisted) {
|
||||||
|
diskPath = await this.attachmentStorage.resolveExistingPath(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (diskPath && shouldServeAttachmentFromDiskPath(await this.attachmentStorage.getFileSize(diskPath))) {
|
if (diskPath && shouldServeAttachmentFromDiskPath(await this.attachmentStorage.getFileSize(diskPath))) {
|
||||||
await this.transport.streamFileFromDiskToPeer(
|
await this.transport.streamFileFromDiskToPeer(
|
||||||
fromPeerId,
|
fromPeerId,
|
||||||
@@ -868,6 +891,10 @@ export class AttachmentTransferService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After a reload the runtime store is only populated once persisted
|
||||||
|
// metadata has been hydrated - announcing before that sees no attachments.
|
||||||
|
await this.persistence.whenReady();
|
||||||
|
|
||||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
if (!canHostAttachment(attachment)) {
|
if (!canHostAttachment(attachment)) {
|
||||||
|
|||||||
+8
@@ -8,6 +8,7 @@ import {
|
|||||||
buildAttachmentDisplayPinKey,
|
buildAttachmentDisplayPinKey,
|
||||||
canRevokeAttachmentDisplayBlob,
|
canRevokeAttachmentDisplayBlob,
|
||||||
collectMessageIdsForInactiveRoomBlobRelease,
|
collectMessageIdsForInactiveRoomBlobRelease,
|
||||||
|
isAttachmentDisplayPinned,
|
||||||
shouldRevokeDisplayBlobForAttachment
|
shouldRevokeDisplayBlobForAttachment
|
||||||
} from './attachment-blob-eviction.rules';
|
} from './attachment-blob-eviction.rules';
|
||||||
|
|
||||||
@@ -60,6 +61,13 @@ describe('attachment-blob-eviction rules', () => {
|
|||||||
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
|
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('protects pinned hydration even before an object URL exists', () => {
|
||||||
|
const pinnedKeys = new Set([buildAttachmentDisplayPinKey('msg-1', 'att-1')]);
|
||||||
|
|
||||||
|
expect(isAttachmentDisplayPinned('msg-1', 'att-1', pinnedKeys)).toBe(true);
|
||||||
|
expect(isAttachmentDisplayPinned('msg-1', 'att-2', pinnedKeys)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
describe('collectMessageIdsForInactiveRoomBlobRelease', () => {
|
describe('collectMessageIdsForInactiveRoomBlobRelease', () => {
|
||||||
const messageRoomIds = new Map([
|
const messageRoomIds = new Map([
|
||||||
['msg-a', 'room-1'],
|
['msg-a', 'room-1'],
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: st
|
|||||||
return `${messageId}:${attachmentId}`;
|
return `${messageId}:${attachmentId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAttachmentDisplayPinned(
|
||||||
|
messageId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
pinnedKeys: ReadonlySet<string>
|
||||||
|
): boolean {
|
||||||
|
return pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
export function canRevokeAttachmentDisplayBlob(
|
export function canRevokeAttachmentDisplayBlob(
|
||||||
attachment: AttachmentDisplayBlobCandidate
|
attachment: AttachmentDisplayBlobCandidate
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -38,7 +46,7 @@ export function shouldRevokeDisplayBlobForAttachment(
|
|||||||
attachment: AttachmentDisplayBlobCandidate & { id: string },
|
attachment: AttachmentDisplayBlobCandidate & { id: string },
|
||||||
pinnedKeys: ReadonlySet<string>
|
pinnedKeys: ReadonlySet<string>
|
||||||
): boolean {
|
): boolean {
|
||||||
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
|
if (isAttachmentDisplayPinned(messageId, attachment.id, pinnedKeys)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -1,4 +1,8 @@
|
|||||||
import { shouldHydrateInlineImageForVisibility, shouldHydratePlayableMediaForVisibility } from './attachment-hydration-visibility.rules';
|
import {
|
||||||
|
resolveAttachmentVisibilityTarget,
|
||||||
|
shouldHydrateInlineImageForVisibility,
|
||||||
|
shouldHydratePlayableMediaForVisibility
|
||||||
|
} from './attachment-hydration-visibility.rules';
|
||||||
|
|
||||||
const diskBackedImage = {
|
const diskBackedImage = {
|
||||||
available: false,
|
available: false,
|
||||||
@@ -15,6 +19,14 @@ const diskBackedVideo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('attachment hydration visibility rules', () => {
|
describe('attachment hydration visibility rules', () => {
|
||||||
|
it('observes the rendered message row instead of a boxless component host', () => {
|
||||||
|
const host = { name: 'component-host' };
|
||||||
|
const renderedRow = { name: 'message-row' };
|
||||||
|
|
||||||
|
expect(resolveAttachmentVisibilityTarget(host, renderedRow)).toBe(renderedRow);
|
||||||
|
expect(resolveAttachmentVisibilityTarget(host, null)).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
it('hydrates a disk-backed image only when the message is visible', () => {
|
it('hydrates a disk-backed image only when the message is visible', () => {
|
||||||
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, true)).toBe(true);
|
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, true)).toBe(true);
|
||||||
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, false)).toBe(false);
|
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, false)).toBe(false);
|
||||||
|
|||||||
+11
@@ -12,6 +12,17 @@ type PlayableMediaCandidate = Pick<
|
|||||||
'available' | 'filePath' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
|
'available' | 'filePath' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angular component hosts can be boxless even when their rendered first child
|
||||||
|
* is a visible message row. Observe and measure that row when it exists.
|
||||||
|
*/
|
||||||
|
export function resolveAttachmentVisibilityTarget<T>(
|
||||||
|
componentHost: T,
|
||||||
|
renderedFirstElement: T | null
|
||||||
|
): T {
|
||||||
|
return renderedFirstElement ?? componentHost;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display blobs are only hydrated for messages inside (or near) the viewport.
|
* Display blobs are only hydrated for messages inside (or near) the viewport.
|
||||||
* Hydrating off-screen rows loads full decoded media into the blob store for
|
* Hydrating off-screen rows loads full decoded media into the blob store for
|
||||||
|
|||||||
+8
-4
@@ -187,6 +187,8 @@
|
|||||||
<img
|
<img
|
||||||
[src]="gridImage.objectUrl"
|
[src]="gridImage.objectUrl"
|
||||||
[alt]="gridImage.filename"
|
[alt]="gridImage.filename"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
class="chat-image-grid-image"
|
class="chat-image-grid-image"
|
||||||
(click)="openLightbox(gridImage)"
|
(click)="openLightbox(gridImage)"
|
||||||
/>
|
/>
|
||||||
@@ -196,7 +198,7 @@
|
|||||||
<div class="chat-image-grid-cell chat-image-grid-loading">
|
<div class="chat-image-grid-cell chat-image-grid-loading">
|
||||||
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
|
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
} @else if ((gridImage.receivedBytes || 0) > 0) {
|
} @else if ((gridImage.receivedBytes || 0) > 0 || isAttachmentRequestPending(gridImage)) {
|
||||||
<div class="chat-image-grid-cell chat-image-grid-loading">
|
<div class="chat-image-grid-cell chat-image-grid-loading">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideImage"
|
name="lucideImage"
|
||||||
@@ -262,6 +264,8 @@
|
|||||||
<img
|
<img
|
||||||
[src]="att.objectUrl"
|
[src]="att.objectUrl"
|
||||||
[alt]="att.filename"
|
[alt]="att.filename"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
class="max-h-80 w-auto cursor-pointer rounded-md"
|
class="max-h-80 w-auto cursor-pointer rounded-md"
|
||||||
(click)="openLightbox(att)"
|
(click)="openLightbox(att)"
|
||||||
/>
|
/>
|
||||||
@@ -296,7 +300,7 @@
|
|||||||
>
|
>
|
||||||
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
|
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0 || isAttachmentRequestPending(att)) {
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatAttachmentCard"
|
appThemeNode="chatAttachmentCard"
|
||||||
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
|
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
|
||||||
@@ -372,7 +376,7 @@
|
|||||||
(downloadRequested)="downloadAttachment(att)"
|
(downloadRequested)="downloadAttachment(att)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0 || isAttachmentRequestPending(att)) {
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatAttachmentCard"
|
appThemeNode="chatAttachmentCard"
|
||||||
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
|
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
|
||||||
@@ -454,7 +458,7 @@
|
|||||||
<span>• {{ formatSpeed(att.speedBps) }}</span>
|
<span>• {{ formatSpeed(att.speedBps) }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (!(att.receivedBytes || 0)) {
|
@if (!(att.receivedBytes || 0) && !isAttachmentRequestPending(att)) {
|
||||||
<button
|
<button
|
||||||
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
|
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
|
||||||
(click)="requestAttachment(att)"
|
(click)="requestAttachment(att)"
|
||||||
|
|||||||
+17
-6
@@ -50,8 +50,11 @@ import {
|
|||||||
isInlineDisplayableImage
|
isInlineDisplayableImage
|
||||||
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
||||||
import { isAttachmentPendingMediaHydration } from '../../../../../attachment/domain/logic/attachment.logic';
|
import { isAttachmentPendingMediaHydration } from '../../../../../attachment/domain/logic/attachment.logic';
|
||||||
import { shouldHydrateInlineImageForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules';
|
import {
|
||||||
import { shouldHydratePlayableMediaForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules';
|
resolveAttachmentVisibilityTarget,
|
||||||
|
shouldHydrateInlineImageForVisibility,
|
||||||
|
shouldHydratePlayableMediaForVisibility
|
||||||
|
} from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules';
|
||||||
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
|
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
|
||||||
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
||||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||||
@@ -541,8 +544,12 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = this.elementRef.nativeElement;
|
const componentHost = this.elementRef.nativeElement;
|
||||||
const scrollRoot = host.closest('[appThemeNode="chatMessageList"]');
|
const visibilityTarget = resolveAttachmentVisibilityTarget(
|
||||||
|
componentHost,
|
||||||
|
componentHost.firstElementChild as HTMLElement | null
|
||||||
|
);
|
||||||
|
const scrollRoot = visibilityTarget.closest('[appThemeNode="chatMessageList"]');
|
||||||
|
|
||||||
this.visibilityObserver = new IntersectionObserver(
|
this.visibilityObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -561,8 +568,8 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.visibilityObserver.observe(host);
|
this.visibilityObserver.observe(visibilityTarget);
|
||||||
this.syncInitialMessageVisibility(host, scrollRoot as HTMLElement | null);
|
this.syncInitialMessageVisibility(visibilityTarget, scrollRoot as HTMLElement | null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncInitialMessageVisibility(host: HTMLElement, scrollRoot: HTMLElement | null): void {
|
private syncInitialMessageVisibility(host: HTMLElement, scrollRoot: HTMLElement | null): void {
|
||||||
@@ -821,6 +828,10 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAttachmentRequestPending(attachment: Attachment): boolean {
|
||||||
|
return this.attachmentsSvc.hasPendingRequest(this.message().id, attachment.id);
|
||||||
|
}
|
||||||
|
|
||||||
cancelAttachment(attachment: Attachment): void {
|
cancelAttachment(attachment: Attachment): void {
|
||||||
const liveAttachment = this.getLiveAttachment(attachment.id);
|
const liveAttachment = this.getLiveAttachment(attachment.id);
|
||||||
|
|
||||||
|
|||||||
+12
@@ -21,4 +21,16 @@ describe('ChatMessageItemComponent template', () => {
|
|||||||
expect(systemMessageBlock?.[1]).toMatch(/\bborder\b/);
|
expect(systemMessageBlock?.[1]).toMatch(/\bborder\b/);
|
||||||
expect(systemMessageBlock?.[1]).toMatch(/\bbg-secondary\/45\b/);
|
expect(systemMessageBlock?.[1]).toMatch(/\bbg-secondary\/45\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads blob-backed inline image thumbnails lazily with async decoding', () => {
|
||||||
|
const attachmentImages = template.match(/<img[\s\S]*?\[src]="(?:gridImage|att)\.objectUrl"[\s\S]*?\/>/g) ?? [];
|
||||||
|
|
||||||
|
expect(attachmentImages).toHaveLength(2);
|
||||||
|
|
||||||
|
for (const image of attachmentImages) {
|
||||||
|
expect(image).toContain('loading="lazy"');
|
||||||
|
expect(image).toContain('decoding="async"');
|
||||||
|
expect(image).not.toContain('ngSrc');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-3
@@ -45,13 +45,17 @@
|
|||||||
<img
|
<img
|
||||||
[src]="tile.attachment.objectUrl"
|
[src]="tile.attachment.objectUrl"
|
||||||
[alt]="tile.attachment.filename"
|
[alt]="tile.attachment.filename"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
class="h-full w-full object-cover transition-transform duration-200 group-hover/gallery:scale-[1.02]"
|
class="h-full w-full object-cover transition-transform duration-200 group-hover/gallery:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/gallery:bg-black/15"></div>
|
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/gallery:bg-black/15"></div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@case ('hydrating') {
|
@case ('hydrating') {
|
||||||
<div class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-border bg-secondary/40 p-3 text-center">
|
<div
|
||||||
|
class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-border bg-secondary/40 p-3 text-center"
|
||||||
|
>
|
||||||
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
|
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -63,7 +67,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@case ('downloading') {
|
@case ('downloading') {
|
||||||
<div class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-border bg-secondary/40 p-3 text-center">
|
<div
|
||||||
|
class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-border bg-secondary/40 p-3 text-center"
|
||||||
|
>
|
||||||
<div class="text-xs font-medium text-primary">
|
<div class="text-xs font-medium text-primary">
|
||||||
{{ ((tile.attachment.receivedBytes || 0) * 100) / tile.attachment.size | number: '1.0-0' }}%
|
{{ ((tile.attachment.receivedBytes || 0) * 100) / tile.attachment.size | number: '1.0-0' }}%
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +92,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@default {
|
@default {
|
||||||
<div class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border bg-secondary/20 p-3 text-center">
|
<div
|
||||||
|
class="flex aspect-square flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border bg-secondary/20 p-3 text-center"
|
||||||
|
>
|
||||||
<span class="line-clamp-2 text-xs text-muted-foreground">{{ tile.attachment.filename }}</span>
|
<span class="line-clamp-2 text-xs text-muted-foreground">{{ tile.attachment.filename }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const template = readFileSync(new URL('./chat-message-overlays.component.html', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
describe('ChatMessageOverlaysComponent template', () => {
|
||||||
|
it('loads gallery thumbnails lazily but leaves fullscreen images eager', () => {
|
||||||
|
const images = template.match(/<img[\s\S]*?\/>/g) ?? [];
|
||||||
|
const galleryImage = images.find((image) => image.includes('[src]="tile.attachment.objectUrl"'));
|
||||||
|
const lightboxImage = images.find((image) => image.includes('[src]="lightboxAttachment()!.objectUrl"'));
|
||||||
|
|
||||||
|
expect(galleryImage).toContain('loading="lazy"');
|
||||||
|
expect(galleryImage).toContain('decoding="async"');
|
||||||
|
expect(galleryImage).not.toContain('ngSrc');
|
||||||
|
expect(lightboxImage).toBeDefined();
|
||||||
|
expect(lightboxImage).not.toContain('loading="lazy"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 382 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
Reference in New Issue
Block a user