diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index d2cc349..949a8ae 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -144,7 +144,9 @@ When the user navigates to a room, the manager watches the route and decides whi The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`. -Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media. Auto-download work fans out with bounded concurrency (`ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY`, default 3 files at a time per watched room) so multiple pending files can progress in parallel without removing the per-file chunk-ack memory safety invariant. +Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media. Auto-download work fans out with bounded concurrency (`ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY`, default 3 files at a time per watched room) so multiple pending files can progress in parallel without removing the per-file chunk-ack memory safety invariant. Stalled partial downloads with no active pending request are reset automatically before the next auto-download pass. + +Incoming and synced attachment metadata is normalized through `attachment-normalize.rules.ts` / `attachment-mime.rules.ts`: generic `application/octet-stream` (or empty) MIME types are inferred from the filename extension so images still group into galleries and small audio/video render as players instead of generic file cards. Display hydration (`needsAttachmentDisplayHydration`) rehydrates blob/file URLs from disk for both inline images and playable media without forcing a fresh peer download when local bytes already exist. Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player. diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts index ee9c441..278a4dc 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts @@ -17,6 +17,7 @@ import { shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic'; import { ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY, runTasksWithBoundedConcurrency } from '../../domain/logic/attachment-autodownload-concurrency.rules'; +import { shouldResetStalledAttachmentDownload } from '../../domain/logic/attachment-autodownload.rules'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import type { FileAnnouncePayload, @@ -379,8 +380,14 @@ export class AttachmentManagerService { if (attachment.available) continue; - if ((attachment.receivedBytes ?? 0) > 0) + if (shouldResetStalledAttachmentDownload( + attachment, + this.transfer.hasPendingRequest(messageId, attachment.id) + )) { + this.transfer.cancelRequest(messageId, attachment); + } else if ((attachment.receivedBytes ?? 0) > 0) { continue; + } if (this.transfer.hasPendingRequest(messageId, attachment.id)) continue; diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts index 7b68050..99ba722 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts @@ -866,4 +866,115 @@ describe('AttachmentTransferService', () => { expect(attachment.available).toBe(false); expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false); }); + + it('normalizes generic octet-stream announces into image metadata for gallery grouping', () => { + const service = createService(); + + expect(service.handleFileAnnounce({ + messageId: MESSAGE_ID, + fromPeerId: PEER_ID, + file: { + id: FILE_ID, + filename: 'grid.png', + size: 512, + mime: 'application/octet-stream', + isImage: false, + uploaderPeerId: PEER_ID + } + })).toBe(true); + + const attachment = runtimeStore.getAttachmentsForMessage(MESSAGE_ID)[0]; + + expect(attachment.mime).toBe('image/png'); + expect(attachment.isImage).toBe(true); + }); + + it('hydrates playable media from disk without re-requesting when display url is missing', async () => { + persistence.tryRestoreAttachmentFromLocal.mockImplementation(async (attachment) => { + attachment.objectUrl = 'file:///appdata/song.mp3'; + attachment.available = true; + return true; + }); + + const service = createService(); + const attachment: Attachment = { + id: FILE_ID, + messageId: MESSAGE_ID, + filename: 'song.mp3', + size: 1024, + mime: 'audio/mpeg', + isImage: false, + uploaderPeerId: PEER_ID, + available: true, + savedPath: '/appdata/song.mp3', + receivedBytes: 0 + }; + + runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]); + + await service.requestFromAnyPeer(MESSAGE_ID, attachment); + + expect(persistence.tryRestoreAttachmentFromLocal).toHaveBeenCalled(); + expect(webrtc.sendToPeer).not.toHaveBeenCalled(); + expect(attachment.objectUrl).toBe('file:///appdata/song.mp3'); + }); + + it('hydrates small in-memory audio downloads with a native file url when available', async () => { + attachmentStorage.getFileUrl.mockResolvedValue('file:///appdata/song.mp3'); + persistence.saveFileToDisk.mockImplementation(async (attachment) => { + attachment.savedPath = '/appdata/song.mp3'; + return attachment.savedPath; + }); + + const service = createService(); + const attachment: Attachment = { + id: FILE_ID, + messageId: MESSAGE_ID, + filename: 'song.mp3', + size: 1024, + mime: 'audio/mpeg', + isImage: false, + uploaderPeerId: PEER_ID, + available: false, + receivedBytes: 0 + }; + + runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]); + + service.handleFileChunk(chunkPayload(0, 1, [ + 1, + 2, + 3 + ])); + + await vi.waitFor(() => expect(attachment.objectUrl).toBe('file:///appdata/song.mp3')); + + expect(attachment.available).toBe(true); + expect(attachment.savedPath).toBe('/appdata/song.mp3'); + }); + + it('does not cancel hydrating gallery retries before attempting local restore', async () => { + persistence.tryRestoreAttachmentFromLocal.mockResolvedValue(true); + + const service = createService(); + const attachment: Attachment = { + id: FILE_ID, + messageId: MESSAGE_ID, + filename: 'grid.png', + size: 512, + mime: 'image/png', + isImage: true, + uploaderPeerId: PEER_ID, + available: false, + savedPath: '/appdata/grid.png', + receivedBytes: 0 + }; + + runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]); + + await service.requestImageFromAnyPeer(MESSAGE_ID, attachment); + + expect(persistence.tryRestoreAttachmentFromLocal).toHaveBeenCalled(); + expect(webrtc.sendToPeer).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'file-cancel' })); + }); }); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index 8bf27f4..eb36e29 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -13,10 +13,13 @@ import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/a import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules'; import { canReceiveAttachment, + needsAttachmentDisplayHydration, shouldCopyLargeUploaderFileToAppData, shouldPersistDownloadedAttachment, shouldStreamAttachmentReceiveToDisk } from '../../domain/logic/attachment.logic'; +import { normalizeAttachmentMeta } from '../../domain/logic/attachment-normalize.rules'; +import { resolveAttachmentMime } from '../../domain/logic/attachment-mime.rules'; import { shouldServeAttachmentFromDiskPath } from '../../domain/logic/attachment-serve.rules'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import { @@ -141,9 +144,11 @@ export class AttachmentTransferService { const alreadyKnown = existing.find((entry) => entry.id === meta.id); if (!alreadyKnown) { - const attachment: Attachment = { ...meta, + const attachment: Attachment = { + ...normalizeAttachmentMeta(meta), available: false, - receivedBytes: 0 }; + receivedBytes: 0 + }; existing.push(attachment); newAttachments.push(attachment); @@ -171,6 +176,16 @@ export class AttachmentTransferService { // request makes the sender stream the file twice and corrupts byte accounting. this.runtimeStore.setPendingRequestPeers(requestKey, new Set()); + if (needsAttachmentDisplayHydration(attachment)) { + const hydratedLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment); + + if (hydratedLocally) { + this.runtimeStore.deletePendingRequest(requestKey); + this.runtimeStore.touch(); + return; + } + } + if (!attachment.available) { const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment); @@ -230,6 +245,10 @@ export class AttachmentTransferService { } requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise { + if (needsAttachmentDisplayHydration(attachment)) { + return this.requestFromAnyPeer(messageId, attachment); + } + if ((attachment.receivedBytes ?? 0) > 0 || this.hasPendingRequest(messageId, attachment.id)) { this.cancelRequest(messageId, attachment); } @@ -259,7 +278,7 @@ export class AttachmentTransferService { messageId, filename: file.name, size: file.size, - mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE, + mime: resolveAttachmentMime(file.name, file.type || DEFAULT_ATTACHMENT_MIME_TYPE), isImage: resolvePublishAttachmentIsImage(file), uploaderPeerId, filePath: (file as LocalFileWithPath).path, @@ -327,29 +346,40 @@ export class AttachmentTransferService { const alreadyKnown = list.find((entry) => entry.id === file.id); if (alreadyKnown) { + alreadyKnown.filename = file.filename; + alreadyKnown.size = file.size; + alreadyKnown.mime = resolveAttachmentMime(file.filename, file.mime); + alreadyKnown.isImage = isImageAttachment({ + filename: file.filename, + isImage: !!file.isImage, + mime: alreadyKnown.mime + }); + + alreadyKnown.uploaderPeerId = file.uploaderPeerId ?? alreadyKnown.uploaderPeerId; + this.runtimeStore.touch(); + void this.persistence.persistAttachmentMeta(alreadyKnown); return false; } - const attachment: Attachment = { + const normalizedMeta = normalizeAttachmentMeta({ id: file.id, messageId, filename: file.filename, size: file.size, mime: file.mime, - isImage: isImageAttachment({ - filename: file.filename, - isImage: !!file.isImage, - mime: file.mime - }), - uploaderPeerId: file.uploaderPeerId, + isImage: !!file.isImage, + uploaderPeerId: file.uploaderPeerId + }); + const runtimeAttachment: Attachment = { + ...normalizedMeta, available: false, receivedBytes: 0 }; - list.push(attachment); + list.push(runtimeAttachment); this.runtimeStore.setAttachmentsForMessage(messageId, list); this.runtimeStore.touch(); - void this.persistence.persistAttachmentMeta(attachment); + void this.persistence.persistAttachmentMeta(runtimeAttachment); return true; } @@ -793,6 +823,10 @@ export class AttachmentTransferService { this.runtimeStore.touch(); void this.persistence.persistAttachmentMeta(attachment); void this.announceLocalHost(attachment); + + if (this.isPlayableMedia(attachment)) { + await this.hydratePlayableMediaAfterDiskReceive(attachment); + } } /** diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload.rules.spec.ts new file mode 100644 index 0000000..1d071eb --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload.rules.spec.ts @@ -0,0 +1,20 @@ +import { shouldResetStalledAttachmentDownload } from './attachment-autodownload.rules'; + +describe('attachment autodownload rules', () => { + it('resets stalled partial downloads that are no longer actively transferring', () => { + expect(shouldResetStalledAttachmentDownload({ + available: false, + receivedBytes: 128 + }, false)).toBe(true); + + expect(shouldResetStalledAttachmentDownload({ + available: false, + receivedBytes: 128 + }, true)).toBe(false); + + expect(shouldResetStalledAttachmentDownload({ + available: true, + receivedBytes: 128 + }, false)).toBe(false); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload.rules.ts new file mode 100644 index 0000000..ca8784a --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-autodownload.rules.ts @@ -0,0 +1,8 @@ +export function shouldResetStalledAttachmentDownload( + attachment: Pick<{ available?: boolean; receivedBytes?: number }, 'available' | 'receivedBytes'>, + hasPendingRequest: boolean +): boolean { + return !attachment.available && + (attachment.receivedBytes ?? 0) > 0 && + !hasPendingRequest; +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-mime.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-mime.rules.spec.ts new file mode 100644 index 0000000..e526a14 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-mime.rules.spec.ts @@ -0,0 +1,41 @@ +import { resolveAttachmentMime } from './attachment-mime.rules'; +import { normalizeAttachmentMeta } from './attachment-normalize.rules'; + +describe('attachment mime rules', () => { + it('keeps explicit audio and video mime types', () => { + expect(resolveAttachmentMime('song.mp3', 'audio/mpeg')).toBe('audio/mpeg'); + expect(resolveAttachmentMime('clip.mp4', 'video/mp4')).toBe('video/mp4'); + }); + + it('infers mime types from filenames when the declared type is generic', () => { + expect(resolveAttachmentMime('song.mp3', 'application/octet-stream')).toBe('audio/mpeg'); + expect(resolveAttachmentMime('clip.webm', '')).toBe('video/webm'); + expect(resolveAttachmentMime('photo.heic', 'application/octet-stream')).toBe('image/heic'); + }); + + it('normalizes synced metadata so images and playable media classify correctly', () => { + expect(normalizeAttachmentMeta({ + id: 'a1', + messageId: 'm1', + filename: 'clip.mp4', + size: 1024, + mime: 'application/octet-stream', + isImage: false + })).toEqual(expect.objectContaining({ + mime: 'video/mp4', + isImage: false + })); + + expect(normalizeAttachmentMeta({ + id: 'a2', + messageId: 'm1', + filename: 'grid.png', + size: 512, + mime: 'application/octet-stream', + isImage: false + })).toEqual(expect.objectContaining({ + mime: 'image/png', + isImage: true + })); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-mime.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-mime.rules.ts new file mode 100644 index 0000000..bd68a93 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-mime.rules.ts @@ -0,0 +1,57 @@ +import { DEFAULT_ATTACHMENT_MIME_TYPE } from '../constants/attachment-transfer.constants'; + +const GENERIC_MIME_TYPES = new Set([ + '', + DEFAULT_ATTACHMENT_MIME_TYPE, + 'binary/octet-stream' +]); +const EXTENSION_MIME_MAP: Record = { + '.aac': 'audio/aac', + '.avi': 'video/x-msvideo', + '.bmp': 'image/bmp', + '.flac': 'audio/flac', + '.gif': 'image/gif', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.m4a': 'audio/mp4', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.ogg': 'audio/ogg', + '.ogv': 'video/ogg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.wav': 'audio/wav', + '.webm': 'video/webm', + '.webp': 'image/webp' +}; + +export function resolveAttachmentMime(filename: string, declaredType?: string | null): string { + const normalizedType = declaredType?.trim() ?? ''; + + if (normalizedType && !GENERIC_MIME_TYPES.has(normalizedType.toLowerCase())) { + return normalizedType; + } + + const extension = extractFilenameExtension(filename); + + if (extension) { + return EXTENSION_MIME_MAP[extension] ?? (normalizedType || DEFAULT_ATTACHMENT_MIME_TYPE); + } + + return normalizedType || DEFAULT_ATTACHMENT_MIME_TYPE; +} + +function extractFilenameExtension(filename: string): string | null { + const normalized = filename.trim().toLowerCase(); + const extensionIndex = normalized.lastIndexOf('.'); + + if (extensionIndex <= 0) { + return null; + } + + return normalized.slice(extensionIndex); +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-normalize.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-normalize.rules.ts new file mode 100644 index 0000000..631b697 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-normalize.rules.ts @@ -0,0 +1,17 @@ +import { isImageAttachment } from './attachment-image.rules'; +import { resolveAttachmentMime } from './attachment-mime.rules'; +import type { AttachmentMeta } from '../models/attachment.model'; + +export function normalizeAttachmentMeta(meta: T): T { + const mime = resolveAttachmentMime(meta.filename, meta.mime); + + return { + ...meta, + mime, + isImage: isImageAttachment({ + filename: meta.filename, + isImage: meta.isImage, + mime + }) + }; +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts index 402597e..5aadd4f 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts @@ -3,6 +3,7 @@ import { isAttachmentPendingMediaHydration, isDirectMessageAttachmentRoomId, isPlayableAttachmentMedia, + needsAttachmentDisplayHydration, shouldCopyUploaderMediaToAppData, shouldCopyLargeUploaderFileToAppData, shouldStreamAttachmentReceiveToDisk, @@ -91,6 +92,33 @@ describe('attachment logic', () => { }, capabilities)).toBe(true); }); + it('combines inline image and playable media hydration needs', () => { + expect(needsAttachmentDisplayHydration({ + filename: 'photo.png', + mime: 'image/png', + isImage: true, + available: false, + savedPath: '/data/photo.png' + })).toBe(true); + + expect(needsAttachmentDisplayHydration({ + filename: 'song.mp3', + mime: 'audio/mpeg', + isImage: false, + available: true, + savedPath: '/data/song.mp3' + })).toBe(true); + + expect(needsAttachmentDisplayHydration({ + filename: 'song.mp3', + mime: 'audio/mpeg', + isImage: false, + available: true, + objectUrl: 'file:///data/song.mp3', + savedPath: '/data/song.mp3' + })).toBe(false); + }); + it('identifies playable media pending hydration from disk paths', () => { expect(isPlayableAttachmentMedia({ mime: 'video/mp4' })).toBe(true); expect(isPlayableAttachmentMedia({ mime: 'image/png' })).toBe(false); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts index ab08216..c5def78 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts @@ -1,3 +1,4 @@ +import { isAttachmentPendingInlineHydration } from './attachment-image.rules'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants'; import type { Attachment } from '../models/attachment.model'; @@ -32,6 +33,15 @@ export function isAttachmentPendingMediaHydration( return !!(attachment.savedPath?.trim() || attachment.filePath?.trim()); } +export function needsAttachmentDisplayHydration( + attachment: Pick< + Attachment, + 'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath' + > +): boolean { + return isAttachmentPendingInlineHydration(attachment) || isAttachmentPendingMediaHydration(attachment); +} + export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean { return attachment.isImage || (isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html index 4c0c51e..9a658a3 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html @@ -51,8 +51,15 @@ } @case ('hydrating') { -
+
+
} @case ('downloading') { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts index 06707d4..42b686b 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts @@ -19,6 +19,7 @@ import { lucideX } from '@ng-icons/lucide'; import { Attachment, AttachmentFacade } from '../../../../../attachment'; +import { isAttachmentPendingInlineHydration } from '../../../../../attachment/domain/logic/attachment-image.rules'; import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules'; import { buildChatMessageGalleryTiles, type ChatMessageGalleryTile } from '../../../../domain/rules/chat-message-image-gallery.rules'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; @@ -127,6 +128,24 @@ export class ChatMessageOverlaysComponent implements OnDestroy { return `${state.index + 1} / ${state.attachments.length}`; }); + private readonly syncGalleryHydration = effect(() => { + const attachments = this.galleryAttachments(); + + void this.attachmentsSvc.updated; + + if (!attachments?.length) { + return; + } + + for (const attachment of attachments) { + if (!isAttachmentPendingInlineHydration(attachment)) { + continue; + } + + void this.attachmentsSvc.tryRestoreAttachmentFromLocal(attachment); + } + }); + private readonly appI18n = inject(AppI18nService); private readonly attachmentsSvc = inject(AttachmentFacade); private readonly LIGHTBOX_CONTROLS_IDLE_MS = 2200; diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts index ef5867d..796d873 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts @@ -520,11 +520,23 @@ export class DmChatComponent { return; } + const displayableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl); + + if (displayableImages.length > 0) { + this.attachments.pinDisplayBlobs(displayableImages); + } + this.galleryMessageId.set(messageId); this.galleryAttachmentOrder.set(attachments.map((attachment) => attachment.id)); } closeImageGallery(): void { + const gallery = this.galleryAttachments(); + + if (gallery) { + this.attachments.unpinDisplayBlobs(gallery); + } + this.galleryMessageId.set(null); this.galleryAttachmentOrder.set([]); }