refactor: stricter domain: attachments
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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.models';
|
||||
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<void> {
|
||||
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<string>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const roomName = await this.resolveCurrentRoomName();
|
||||
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
|
||||
|
||||
if (!diskPath)
|
||||
return;
|
||||
|
||||
attachment.savedPath = diskPath;
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
} catch { /* disk save is best-effort */ }
|
||||
}
|
||||
|
||||
async initFromDatabase(): Promise<void> {
|
||||
await this.loadFromDatabase();
|
||||
await this.migrateFromLocalStorage();
|
||||
await this.tryLoadSavedFiles();
|
||||
}
|
||||
|
||||
async resolveMessageRoomId(messageId: string): Promise<string | null> {
|
||||
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<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
this.ngrxStore
|
||||
.select(selectCurrentRoomName)
|
||||
.pipe(take(1))
|
||||
.subscribe((name) => resolve(name || ''));
|
||||
});
|
||||
}
|
||||
|
||||
private async loadFromDatabase(): Promise<void> {
|
||||
try {
|
||||
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.available)
|
||||
continue;
|
||||
|
||||
if (attachment.savedPath) {
|
||||
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
|
||||
|
||||
if (savedBase64) {
|
||||
this.restoreAttachmentFromDisk(attachment, savedBase64);
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.filePath) {
|
||||
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 getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
|
||||
const retainedSavedPaths = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user