Fix wonkyness #18

Merged
myxelium merged 11 commits from fix-bug into main 2026-07-14 09:28:25 +00:00
14 changed files with 388 additions and 15 deletions
Showing only changes of commit 0078c320a5 - Show all commits
@@ -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`. 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:<conversationId>`, 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:<conversationId>`, 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. 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.
@@ -17,6 +17,7 @@ import {
shouldAutoRequestWhenWatched shouldAutoRequestWhenWatched
} from '../../domain/logic/attachment.logic'; } from '../../domain/logic/attachment.logic';
import { ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY, runTasksWithBoundedConcurrency } from '../../domain/logic/attachment-autodownload-concurrency.rules'; 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 { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import type { import type {
FileAnnouncePayload, FileAnnouncePayload,
@@ -379,8 +380,14 @@ export class AttachmentManagerService {
if (attachment.available) if (attachment.available)
continue; 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; continue;
}
if (this.transfer.hasPendingRequest(messageId, attachment.id)) if (this.transfer.hasPendingRequest(messageId, attachment.id))
continue; continue;
@@ -866,4 +866,115 @@ describe('AttachmentTransferService', () => {
expect(attachment.available).toBe(false); expect(attachment.available).toBe(false);
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).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' }));
});
}); });
@@ -13,10 +13,13 @@ import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/a
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules'; import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
import { import {
canReceiveAttachment, canReceiveAttachment,
needsAttachmentDisplayHydration,
shouldCopyLargeUploaderFileToAppData, shouldCopyLargeUploaderFileToAppData,
shouldPersistDownloadedAttachment, shouldPersistDownloadedAttachment,
shouldStreamAttachmentReceiveToDisk shouldStreamAttachmentReceiveToDisk
} from '../../domain/logic/attachment.logic'; } 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 { shouldServeAttachmentFromDiskPath } from '../../domain/logic/attachment-serve.rules';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { import {
@@ -141,9 +144,11 @@ export class AttachmentTransferService {
const alreadyKnown = existing.find((entry) => entry.id === meta.id); const alreadyKnown = existing.find((entry) => entry.id === meta.id);
if (!alreadyKnown) { if (!alreadyKnown) {
const attachment: Attachment = { ...meta, const attachment: Attachment = {
...normalizeAttachmentMeta(meta),
available: false, available: false,
receivedBytes: 0 }; receivedBytes: 0
};
existing.push(attachment); existing.push(attachment);
newAttachments.push(attachment); newAttachments.push(attachment);
@@ -171,6 +176,16 @@ export class AttachmentTransferService {
// 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>());
if (needsAttachmentDisplayHydration(attachment)) {
const hydratedLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
if (hydratedLocally) {
this.runtimeStore.deletePendingRequest(requestKey);
this.runtimeStore.touch();
return;
}
}
if (!attachment.available) { if (!attachment.available) {
const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment); const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
@@ -230,6 +245,10 @@ export class AttachmentTransferService {
} }
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> { requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
if (needsAttachmentDisplayHydration(attachment)) {
return this.requestFromAnyPeer(messageId, attachment);
}
if ((attachment.receivedBytes ?? 0) > 0 || this.hasPendingRequest(messageId, attachment.id)) { if ((attachment.receivedBytes ?? 0) > 0 || this.hasPendingRequest(messageId, attachment.id)) {
this.cancelRequest(messageId, attachment); this.cancelRequest(messageId, attachment);
} }
@@ -259,7 +278,7 @@ export class AttachmentTransferService {
messageId, messageId,
filename: file.name, filename: file.name,
size: file.size, size: file.size,
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE, mime: resolveAttachmentMime(file.name, file.type || DEFAULT_ATTACHMENT_MIME_TYPE),
isImage: resolvePublishAttachmentIsImage(file), isImage: resolvePublishAttachmentIsImage(file),
uploaderPeerId, uploaderPeerId,
filePath: (file as LocalFileWithPath).path, filePath: (file as LocalFileWithPath).path,
@@ -327,29 +346,40 @@ export class AttachmentTransferService {
const alreadyKnown = list.find((entry) => entry.id === file.id); const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown) { 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; return false;
} }
const attachment: Attachment = { const normalizedMeta = normalizeAttachmentMeta({
id: file.id, id: file.id,
messageId, messageId,
filename: file.filename, filename: file.filename,
size: file.size, size: file.size,
mime: file.mime, mime: file.mime,
isImage: isImageAttachment({ isImage: !!file.isImage,
filename: file.filename, uploaderPeerId: file.uploaderPeerId
isImage: !!file.isImage, });
mime: file.mime const runtimeAttachment: Attachment = {
}), ...normalizedMeta,
uploaderPeerId: file.uploaderPeerId,
available: false, available: false,
receivedBytes: 0 receivedBytes: 0
}; };
list.push(attachment); list.push(runtimeAttachment);
this.runtimeStore.setAttachmentsForMessage(messageId, list); this.runtimeStore.setAttachmentsForMessage(messageId, list);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(runtimeAttachment);
return true; return true;
} }
@@ -793,6 +823,10 @@ export class AttachmentTransferService {
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment); void this.announceLocalHost(attachment);
if (this.isPlayableMedia(attachment)) {
await this.hydratePlayableMediaAfterDiskReceive(attachment);
}
} }
/** /**
@@ -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);
});
});
@@ -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;
}
@@ -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
}));
});
});
@@ -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<string, string> = {
'.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);
}
@@ -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<T extends AttachmentMeta>(meta: T): T {
const mime = resolveAttachmentMime(meta.filename, meta.mime);
return {
...meta,
mime,
isImage: isImageAttachment({
filename: meta.filename,
isImage: meta.isImage,
mime
})
};
}
@@ -3,6 +3,7 @@ import {
isAttachmentPendingMediaHydration, isAttachmentPendingMediaHydration,
isDirectMessageAttachmentRoomId, isDirectMessageAttachmentRoomId,
isPlayableAttachmentMedia, isPlayableAttachmentMedia,
needsAttachmentDisplayHydration,
shouldCopyUploaderMediaToAppData, shouldCopyUploaderMediaToAppData,
shouldCopyLargeUploaderFileToAppData, shouldCopyLargeUploaderFileToAppData,
shouldStreamAttachmentReceiveToDisk, shouldStreamAttachmentReceiveToDisk,
@@ -91,6 +92,33 @@ describe('attachment logic', () => {
}, capabilities)).toBe(true); }, 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', () => { it('identifies playable media pending hydration from disk paths', () => {
expect(isPlayableAttachmentMedia({ mime: 'video/mp4' })).toBe(true); expect(isPlayableAttachmentMedia({ mime: 'video/mp4' })).toBe(true);
expect(isPlayableAttachmentMedia({ mime: 'image/png' })).toBe(false); expect(isPlayableAttachmentMedia({ mime: 'image/png' })).toBe(false);
@@ -1,3 +1,4 @@
import { isAttachmentPendingInlineHydration } from './attachment-image.rules';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants';
import type { Attachment } from '../models/attachment.model'; import type { Attachment } from '../models/attachment.model';
@@ -32,6 +33,15 @@ export function isAttachmentPendingMediaHydration(
return !!(attachment.savedPath?.trim() || attachment.filePath?.trim()); 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 { export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
return attachment.isImage || return attachment.isImage ||
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES); (isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
@@ -51,8 +51,15 @@
</button> </button>
} }
@case ('hydrating') { @case ('hydrating') {
<div class="flex aspect-square items-center justify-center rounded-md border border-border bg-secondary/40"> <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
type="button"
class="rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
(click)="retryGalleryImage(tile.attachment)"
>
{{ 'chat.message.retry' | translate }}
</button>
</div> </div>
} }
@case ('downloading') { @case ('downloading') {
@@ -19,6 +19,7 @@ import {
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { Attachment, AttachmentFacade } from '../../../../../attachment'; import { Attachment, AttachmentFacade } from '../../../../../attachment';
import { isAttachmentPendingInlineHydration } from '../../../../../attachment/domain/logic/attachment-image.rules';
import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules'; import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules';
import { buildChatMessageGalleryTiles, type ChatMessageGalleryTile } from '../../../../domain/rules/chat-message-image-gallery.rules'; import { buildChatMessageGalleryTiles, type ChatMessageGalleryTile } from '../../../../domain/rules/chat-message-image-gallery.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
@@ -127,6 +128,24 @@ export class ChatMessageOverlaysComponent implements OnDestroy {
return `${state.index + 1} / ${state.attachments.length}`; 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 appI18n = inject(AppI18nService);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly LIGHTBOX_CONTROLS_IDLE_MS = 2200; private readonly LIGHTBOX_CONTROLS_IDLE_MS = 2200;
@@ -520,11 +520,23 @@ export class DmChatComponent {
return; return;
} }
const displayableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
if (displayableImages.length > 0) {
this.attachments.pinDisplayBlobs(displayableImages);
}
this.galleryMessageId.set(messageId); this.galleryMessageId.set(messageId);
this.galleryAttachmentOrder.set(attachments.map((attachment) => attachment.id)); this.galleryAttachmentOrder.set(attachments.map((attachment) => attachment.id));
} }
closeImageGallery(): void { closeImageGallery(): void {
const gallery = this.galleryAttachments();
if (gallery) {
this.attachments.unpinDisplayBlobs(gallery);
}
this.galleryMessageId.set(null); this.galleryMessageId.set(null);
this.galleryAttachmentOrder.set([]); this.galleryAttachmentOrder.set([]);
} }