import { Injectable, inject } from '@angular/core'; import { take } from 'rxjs'; import { Store } from '@ngrx/store'; import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants'; import { ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, decodeBase64ToUint8Array, yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules'; import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules'; import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules'; import { AttachmentRuntimeStore } from './attachment-runtime.store'; @Injectable({ providedIn: 'root' }) export class AttachmentPersistenceService { private initPromise: Promise | null = null; private readonly runtimeStore = inject(AttachmentRuntimeStore); private readonly ngrxStore = inject(Store); private readonly attachmentStorage = inject(AttachmentStorageService); private readonly database = inject(DatabaseService); async deleteForMessage(messageId: string): Promise { const attachments = this.runtimeStore.getAttachmentsForMessage(messageId); const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId); const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId); const savedPathsToDelete = new Set(); for (const attachment of attachments) { if (attachment.objectUrl) { try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } } if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) { savedPathsToDelete.add(attachment.savedPath); } } this.runtimeStore.deleteAttachmentsForMessage(messageId); this.runtimeStore.deleteMessageRoom(messageId); this.runtimeStore.clearMessageScopedState(messageId); if (hadCachedAttachments) { this.runtimeStore.touch(); } if (this.database.isReady()) { await this.database.deleteAttachmentsForMessage(messageId); } for (const diskPath of savedPathsToDelete) { await this.attachmentStorage.deleteFile(diskPath); } } whenReady(): Promise { if (this.database.isReady()) { return this.initFromDatabase(); } return this.initPromise ?? Promise.resolve(); } async persistAttachmentMeta(attachment: Attachment): Promise { if (!this.database.isReady()) return; try { const storedRecords = await this.database.getAttachmentsForMessage(attachment.messageId); const storedRecord = storedRecords.find((record) => record.id === attachment.id); const localPaths = mergeAttachmentLocalPaths(attachment, storedRecord); attachment.filePath = localPaths.filePath ?? undefined; attachment.savedPath = localPaths.savedPath ?? undefined; await this.database.saveAttachment({ id: attachment.id, messageId: attachment.messageId, filename: attachment.filename, size: attachment.size, mime: attachment.mime, isImage: attachment.isImage, uploaderPeerId: attachment.uploaderPeerId, filePath: localPaths.filePath ?? undefined, savedPath: localPaths.savedPath ?? undefined }); } catch { /* persistence is best-effort */ } } async saveFileToDisk(attachment: Attachment, blob: Blob): Promise { try { const storageContainer = await this.resolveStorageContainerName(attachment); const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, storageContainer); if (!diskPath) return null; attachment.savedPath = diskPath; void this.persistAttachmentMeta(attachment); return diskPath; } catch { /* disk save is best-effort */ } return null; } async initFromDatabase(): Promise { if (!this.initPromise) { this.initPromise = this.runInitFromDatabase(); } return this.initPromise; } async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise { const restored = await this.ensureInlineDisplayObjectUrl(attachment); if (restored) { attachment.requestError = undefined; } return restored; } async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise { if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) { return true; } let diskPath = await this.attachmentStorage.resolveExistingPath(attachment); if (!diskPath) { const roomName = await this.resolveStorageContainerName(attachment); diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName); if (diskPath) { attachment.savedPath = diskPath; void this.persistAttachmentMeta(attachment); } } if (!diskPath) { return false; } if (this.attachmentStorage.providesInlineObjectUrl()) { const nativeUrl = await this.attachmentStorage.getFileUrl(diskPath); if (nativeUrl) { this.revokeAttachmentObjectUrl(attachment); attachment.objectUrl = nativeUrl; attachment.available = true; return true; } } this.revokeAttachmentObjectUrl(attachment); const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath); return restored; } async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise { try { const storageContainer = await this.resolveStorageContainerName(attachment); const diskPath = await this.attachmentStorage.createWritableFile(attachment, storageContainer); if (!diskPath) { return null; } const copied = await this.attachmentStorage.copyFile(sourcePath, diskPath); if (!copied) { await this.attachmentStorage.deleteFile(diskPath); return null; } attachment.savedPath = diskPath; await this.persistAttachmentMeta(attachment); return diskPath; } catch { return null; } } async resolveMessageRoomId(messageId: string): Promise { const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId); if (cachedRoomId) return cachedRoomId; if (!this.database.isReady()) return null; try { const message = await this.database.getMessageById(messageId); if (!message?.roomId) return null; this.runtimeStore.rememberMessageRoom(messageId, message.roomId); return message.roomId; } catch { return null; } } async resolveCurrentRoomName(): Promise { return new Promise((resolve) => { this.ngrxStore .select(selectCurrentRoomName) .pipe(take(1)) .subscribe((name) => resolve(name || '')); }); } async resolveStorageContainerName(attachment: Pick): Promise { return this.runtimeStore.getMessageRoomId(attachment.messageId) ?? await this.resolveCurrentRoomName(); } private async loadFromDatabase(): Promise { try { const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); const grouped = new Map(); for (const record of allRecords) { const attachment: Attachment = { ...record, available: false }; const bucket = grouped.get(record.messageId) ?? []; bucket.push(attachment); grouped.set(record.messageId, bucket); } this.runtimeStore.replaceAttachments(grouped); this.runtimeStore.touch(); } catch { /* load is best-effort */ } } private async migrateFromLocalStorage(): Promise { try { const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY); if (!raw) return; const legacyRecords: AttachmentMeta[] = JSON.parse(raw); for (const meta of legacyRecords) { const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)]; if (!existing.find((entry) => entry.id === meta.id)) { const attachment: Attachment = { ...meta, available: false }; existing.push(attachment); this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing); void this.persistAttachmentMeta(attachment); } } localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY); this.runtimeStore.touch(); } catch { /* migration is best-effort */ } } private async runInitFromDatabase(): Promise { await this.loadFromDatabase(); await this.migrateFromLocalStorage(); } private async restoreAttachmentBlobFromDiskPath(attachment: Attachment, diskPath: string): Promise { if (this.attachmentStorage.canReadFileChunks()) { const fileSize = await this.attachmentStorage.getFileSize(diskPath); if (!fileSize || fileSize < 1) { return false; } const blobParts: Uint8Array[] = []; for (let start = 0; start < fileSize; start += ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES) { const end = Math.min(start + ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, fileSize); const chunkBase64 = await this.attachmentStorage.readFileChunk(diskPath, start, end); if (!chunkBase64) { return false; } blobParts.push(decodeBase64ToUint8Array(chunkBase64)); if (end < fileSize) { await yieldToAttachmentHydrationLoop(); } } this.applyAttachmentBlob(attachment, new Blob(blobParts as BlobPart[], { type: attachment.mime })); return true; } const base64 = await this.attachmentStorage.readFile(diskPath); if (!base64) { return false; } const bytes = decodeBase64ToUint8Array(base64); this.applyAttachmentBlob( attachment, new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime }) ); return true; } private applyAttachmentBlob(attachment: Attachment, blob: Blob): void { attachment.objectUrl = URL.createObjectURL(blob); attachment.available = true; this.runtimeStore.setOriginalFile( `${attachment.messageId}:${attachment.id}`, new File([blob], attachment.filename, { type: attachment.mime }) ); } private revokeAttachmentObjectUrl(attachment: Attachment): void { if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) { return; } try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } } private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise> { const retainedSavedPaths = new Set(); for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) { if (existingMessageId === messageId) continue; for (const attachment of attachments) { if (attachment.savedPath) { retainedSavedPaths.add(attachment.savedPath); } } } if (!this.database.isReady()) { return retainedSavedPaths; } const persistedAttachments = await this.database.getAllAttachments(); for (const attachment of persistedAttachments) { if (attachment.messageId !== messageId && attachment.savedPath) { retainedSavedPaths.add(attachment.savedPath); } } return retainedSavedPaths; } }