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 { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants'; import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants'; import { AttachmentRuntimeStore } from './attachment-runtime.store'; @Injectable({ providedIn: 'root' }) export class AttachmentPersistenceService { 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); } } async persistAttachmentMeta(attachment: Attachment): Promise { if (!this.database.isReady()) return; try { 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: attachment.filePath, savedPath: attachment.savedPath }); } 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 { await this.loadFromDatabase(); await this.migrateFromLocalStorage(); await this.tryLoadSavedFiles(); } 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 tryLoadSavedFiles(): Promise { try { let hasChanges = false; for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) { for (const attachment of attachments) { if (attachment.available) continue; if (attachment.savedPath) { if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) { hasChanges = true; continue; } const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath); if (savedBase64) { this.restoreAttachmentFromDisk(attachment, savedBase64); hasChanges = true; continue; } } if (attachment.filePath) { if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) { hasChanges = true; continue; } const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath); if (originalBase64) { this.restoreAttachmentFromDisk(attachment, originalBase64); hasChanges = true; if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) { const response = await fetch(attachment.objectUrl); void this.saveFileToDisk(attachment, await response.blob()); } continue; } } } } if (hasChanges) this.runtimeStore.touch(); } catch { /* startup load is best-effort */ } } private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void { const bytes = this.base64ToUint8Array(base64); const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime }); attachment.objectUrl = URL.createObjectURL(blob); attachment.available = true; this.runtimeStore.setOriginalFile( `${attachment.messageId}:${attachment.id}`, new File([blob], attachment.filename, { type: attachment.mime }) ); } private async restoreMediaAttachmentFromFileUrl(attachment: Attachment, filePath: string): Promise { if (!this.isPlayableMedia(attachment)) { return false; } const fileUrl = await this.attachmentStorage.getFileUrl(filePath); if (!fileUrl) { return false; } attachment.objectUrl = fileUrl; attachment.available = true; return true; } private isPlayableMedia(attachment: Pick): boolean { return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/'); } 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; } private base64ToUint8Array(base64: string): Uint8Array { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } return bytes; } }