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)
@@ -93,6 +93,12 @@ export class AttachmentFacade {
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
}
cancelDisplayHydrationForMessage(
...args: Parameters<AttachmentManagerService['cancelDisplayHydrationForMessage']>
): ReturnType<AttachmentManagerService['cancelDisplayHydrationForMessage']> {
return this.manager.cancelDisplayHydrationForMessage(...args);
}
requestFile(
...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> {
@@ -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);
});
});
@@ -13,6 +13,7 @@ import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-bl
import {
buildAttachmentDisplayPinKey,
collectMessageIdsForInactiveRoomBlobRelease,
isAttachmentDisplayPinned,
shouldRevokeDisplayBlobForAttachment
} from '../../domain/logic/attachment-blob-eviction.rules';
import {
@@ -214,6 +215,8 @@ export class AttachmentManagerService {
return;
}
this.cancelDisplayHydrationForMessage(messageId);
let hasChanges = false;
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
@@ -231,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 {
const messageIds = collectMessageIdsForInactiveRoomBlobRelease(
Array.from(this.runtimeStore.getAttachmentEntries(), ([messageId]) => messageId),
@@ -256,9 +273,11 @@ export class AttachmentManagerService {
}
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);
}
}
@@ -98,6 +98,58 @@ describe('AttachmentPersistenceService', () => {
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 () => {
const injector = Injector.create({
providers: [
@@ -134,6 +186,144 @@ describe('AttachmentPersistenceService', () => {
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 () => {
const injector = Injector.create({
providers: [
@@ -281,13 +471,24 @@ describe('AttachmentPersistenceService', () => {
isImage: true,
savedPath: '/appdata/photo.png',
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);
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
expect(attachment.objectUrl).toBeUndefined();
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');
revokeSpy.mockRestore();
@@ -17,9 +17,19 @@ import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persist
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
const MAX_CONCURRENT_DISPLAY_HYDRATIONS = 2;
interface DisplayHydrationTask {
cancelled: boolean;
promise: Promise<boolean>;
}
@Injectable({ providedIn: 'root' })
export class AttachmentPersistenceService {
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 ngrxStore = inject(Store);
@@ -33,6 +43,8 @@ export class AttachmentPersistenceService {
const savedPathsToDelete = new Set<string>();
for (const attachment of attachments) {
this.cancelDisplayHydration(attachment);
if (attachment.objectUrl) {
try {
URL.revokeObjectURL(attachment.objectUrl);
@@ -133,13 +145,34 @@ export class AttachmentPersistenceService {
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 {
this.cancelDisplayHydration(attachment);
if (!canRevokeAttachmentDisplayBlob(attachment)) {
return false;
}
this.revokeAttachmentObjectUrl(attachment);
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:
// peer requests are served from the disk path and re-display rehydrates
@@ -199,19 +232,64 @@ export class AttachmentPersistenceService {
return true;
}
async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
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);
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
return false;
}
if (!diskPath) {
const roomName = await this.resolveStorageContainerName(attachment);
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
return false;
}
diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName);
if (diskPath) {
if (diskPath && this.isDisplayHydrationCurrent(hydrationKey, task)) {
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
}
@@ -224,7 +302,7 @@ export class AttachmentPersistenceService {
if (this.attachmentStorage.providesInlineObjectUrl()) {
const nativeUrl = await this.attachmentStorage.getFileUrl(diskPath);
if (nativeUrl) {
if (nativeUrl && this.isDisplayHydrationCurrent(hydrationKey, task)) {
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = nativeUrl;
attachment.available = true;
@@ -233,9 +311,18 @@ export class AttachmentPersistenceService {
}
}
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
return false;
}
this.revokeAttachmentObjectUrl(attachment);
const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath);
const restored = await this.restoreAttachmentBlobFromDiskPath(
attachment,
diskPath,
hydrationKey,
task
);
return restored;
}
@@ -302,14 +389,29 @@ export class AttachmentPersistenceService {
private async loadFromDatabase(): Promise<void> {
try {
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) {
const attachment: Attachment = { ...record,
available: false };
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,
available: false };
bucket.push(attachment);
}
bucket.push(attachment);
grouped.set(record.messageId, bucket);
}
@@ -350,11 +452,16 @@ export class AttachmentPersistenceService {
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()) {
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
if (!fileSize || fileSize < 1) {
if (!fileSize || fileSize < 1 || !this.isDisplayHydrationCurrent(hydrationKey, task)) {
return false;
}
@@ -364,7 +471,7 @@ export class AttachmentPersistenceService {
const end = Math.min(start + ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, fileSize);
const chunkBase64 = await this.attachmentStorage.readFileChunk(diskPath, start, end);
if (!chunkBase64) {
if (!chunkBase64 || !this.isDisplayHydrationCurrent(hydrationKey, task)) {
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 }));
return true;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
if (!base64 || !this.isDisplayHydrationCurrent(hydrationKey, task)) {
return false;
}
const bytes = decodeBase64ToUint8Array(base64);
if (!this.isDisplayHydrationCurrent(hydrationKey, task)) {
return false;
}
this.applyAttachmentBlob(
attachment,
new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime })
@@ -395,6 +510,52 @@ export class AttachmentPersistenceService {
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
// disk and no original-file copy is cached; caching one would keep a second
// full copy of the bytes alive for the whole session.
@@ -986,6 +986,43 @@ describe('AttachmentTransferService', () => {
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', () => {
const service = createService();
@@ -169,12 +169,14 @@ export class AttachmentTransferService {
async requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
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,
// message sync, peer connect) cannot double-request the same file - a duplicate
// request makes the sender stream the file twice and corrupts byte accounting.
this.runtimeStore.setPendingRequestPeers(requestKey, new Set<string>());
this.runtimeStore.touch();
if (needsAttachmentDisplayHydration(attachment)) {
const hydratedLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
@@ -222,10 +224,12 @@ export class AttachmentTransferService {
return;
}
if (clearedRequestError)
this.runtimeStore.touch();
const didSendRequest = this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
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 {
@@ -8,6 +8,7 @@ import {
buildAttachmentDisplayPinKey,
canRevokeAttachmentDisplayBlob,
collectMessageIdsForInactiveRoomBlobRelease,
isAttachmentDisplayPinned,
shouldRevokeDisplayBlobForAttachment
} from './attachment-blob-eviction.rules';
@@ -60,6 +61,13 @@ describe('attachment-blob-eviction rules', () => {
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', () => {
const messageRoomIds = new Map([
['msg-a', 'room-1'],
@@ -15,6 +15,14 @@ export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: st
return `${messageId}:${attachmentId}`;
}
export function isAttachmentDisplayPinned(
messageId: string,
attachmentId: string,
pinnedKeys: ReadonlySet<string>
): boolean {
return pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachmentId));
}
export function canRevokeAttachmentDisplayBlob(
attachment: AttachmentDisplayBlobCandidate
): boolean {
@@ -38,7 +46,7 @@ export function shouldRevokeDisplayBlobForAttachment(
attachment: AttachmentDisplayBlobCandidate & { id: string },
pinnedKeys: ReadonlySet<string>
): boolean {
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
if (isAttachmentDisplayPinned(messageId, attachment.id, pinnedKeys)) {
return false;
}
@@ -1,4 +1,8 @@
import { shouldHydrateInlineImageForVisibility, shouldHydratePlayableMediaForVisibility } from './attachment-hydration-visibility.rules';
import {
resolveAttachmentVisibilityTarget,
shouldHydrateInlineImageForVisibility,
shouldHydratePlayableMediaForVisibility
} from './attachment-hydration-visibility.rules';
const diskBackedImage = {
available: false,
@@ -15,6 +19,14 @@ const diskBackedVideo = {
};
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', () => {
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, true)).toBe(true);
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, false)).toBe(false);
@@ -12,6 +12,17 @@ type PlayableMediaCandidate = Pick<
'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.
* Hydrating off-screen rows loads full decoded media into the blob store for
@@ -187,6 +187,8 @@
<img
[src]="gridImage.objectUrl"
[alt]="gridImage.filename"
loading="lazy"
decoding="async"
class="chat-image-grid-image"
(click)="openLightbox(gridImage)"
/>
@@ -196,7 +198,7 @@
<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>
} @else if ((gridImage.receivedBytes || 0) > 0) {
} @else if ((gridImage.receivedBytes || 0) > 0 || isAttachmentRequestPending(gridImage)) {
<div class="chat-image-grid-cell chat-image-grid-loading">
<ng-icon
name="lucideImage"
@@ -262,6 +264,8 @@
<img
[src]="att.objectUrl"
[alt]="att.filename"
loading="lazy"
decoding="async"
class="max-h-80 w-auto cursor-pointer rounded-md"
(click)="openLightbox(att)"
/>
@@ -296,7 +300,7 @@
>
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
} @else if ((att.receivedBytes || 0) > 0 || isAttachmentRequestPending(att)) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
@@ -372,7 +376,7 @@
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
} @else if ((att.receivedBytes || 0) > 0 || isAttachmentRequestPending(att)) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
@@ -454,7 +458,7 @@
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
@if (!(att.receivedBytes || 0) && !isAttachmentRequestPending(att)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
@@ -50,8 +50,11 @@ import {
isInlineDisplayableImage
} from '../../../../../attachment/domain/logic/attachment-image.rules';
import { isAttachmentPendingMediaHydration } from '../../../../../attachment/domain/logic/attachment.logic';
import { shouldHydrateInlineImageForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules';
import { shouldHydratePlayableMediaForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules';
import {
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 { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
@@ -541,8 +544,12 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
return;
}
const host = this.elementRef.nativeElement;
const scrollRoot = host.closest('[appThemeNode="chatMessageList"]');
const componentHost = this.elementRef.nativeElement;
const visibilityTarget = resolveAttachmentVisibilityTarget(
componentHost,
componentHost.firstElementChild as HTMLElement | null
);
const scrollRoot = visibilityTarget.closest('[appThemeNode="chatMessageList"]');
this.visibilityObserver = new IntersectionObserver(
(entries) => {
@@ -561,8 +568,8 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
}
);
this.visibilityObserver.observe(host);
this.syncInitialMessageVisibility(host, scrollRoot as HTMLElement | null);
this.visibilityObserver.observe(visibilityTarget);
this.syncInitialMessageVisibility(visibilityTarget, scrollRoot as HTMLElement | null);
}
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 {
const liveAttachment = this.getLiveAttachment(attachment.id);
@@ -21,4 +21,16 @@ describe('ChatMessageItemComponent template', () => {
expect(systemMessageBlock?.[1]).toMatch(/\bborder\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');
}
});
});
@@ -45,13 +45,17 @@
<img
[src]="tile.attachment.objectUrl"
[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]"
/>
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/gallery:bg-black/15"></div>
</button>
}
@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>
<button
type="button"
@@ -63,7 +67,9 @@
</div>
}
@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">
{{ ((tile.attachment.receivedBytes || 0) * 100) / tile.attachment.size | number: '1.0-0' }}%
</div>
@@ -86,7 +92,9 @@
</div>
}
@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>
<button
type="button"
@@ -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"');
});
});