fix: Bug - Images and files in chat doesn't load

This commit is contained in:
2026-07-14 11:27:19 +02:00
parent 41ebaf2407
commit 20d7f22fd2
21 changed files with 631 additions and 38 deletions
@@ -115,7 +115,7 @@ 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.
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.
@@ -194,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.
`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
@@ -212,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:
- **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.
- **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.
@@ -220,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`).
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
- [`agents-docs/features/attachments.md`](../../../../../agents-docs/features/attachments.md)