/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */ import { Injectable, inject, signal, effect } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { take } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { WebRTCService } from './webrtc.service'; import { Store } from '@ngrx/store'; import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; import { DatabaseService } from './database.service'; import { recordDebugNetworkFileChunk } from './debug-network-metrics.service'; import { ROOM_URL_PATTERN } from '../constants'; import type { ChatAttachmentAnnouncement, ChatAttachmentMeta, ChatEvent } from '../models/index'; /** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB /** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */ export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB /** * EWMA smoothing weight for the *previous* speed estimate. * The complementary weight (1 − this value) is applied to the * instantaneous measurement. */ const EWMA_PREVIOUS_WEIGHT = 0.7; const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT; /** Fallback MIME type when none is provided by the sender. */ const DEFAULT_MIME_TYPE = 'application/octet-stream'; /** localStorage key used by the legacy attachment store (migration target). */ const LEGACY_STORAGE_KEY = 'metoyou_attachments'; /** User-facing error when no peers are available for a request. */ const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.'; /** User-facing error when connected peers cannot provide a requested file. */ const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.'; /** * Metadata describing a file attachment linked to a chat message. */ export type AttachmentMeta = ChatAttachmentMeta; /** * Runtime representation of an attachment including download * progress and blob URL state. */ export interface Attachment extends AttachmentMeta { /** Whether the file content is available locally (blob URL set). */ available: boolean; /** Object URL for in-browser rendering / download. */ objectUrl?: string; /** Number of bytes received so far (during chunked download). */ receivedBytes?: number; /** Estimated download speed (bytes / second), EWMA-smoothed. */ speedBps?: number; /** Epoch ms when the download started. */ startedAtMs?: number; /** Epoch ms of the most recent chunk received. */ lastUpdateMs?: number; /** User-facing request failure shown in the attachment card. */ requestError?: string; } type FileAnnounceEvent = ChatEvent & { type: 'file-announce'; messageId: string; file: ChatAttachmentAnnouncement; }; type FileChunkEvent = ChatEvent & { type: 'file-chunk'; messageId: string; fileId: string; index: number; total: number; data: string; fromPeerId?: string; }; type FileRequestEvent = ChatEvent & { type: 'file-request'; messageId: string; fileId: string; fromPeerId?: string; }; type FileCancelEvent = ChatEvent & { type: 'file-cancel'; messageId: string; fileId: string; fromPeerId?: string; }; type FileNotFoundEvent = ChatEvent & { type: 'file-not-found'; messageId: string; fileId: string; }; type FileAnnouncePayload = Pick; interface FileChunkPayload { messageId?: string; fileId?: string; fromPeerId?: string; index?: number; total?: number; data?: ChatEvent['data']; } type FileRequestPayload = Pick; type FileCancelPayload = Pick; type FileNotFoundPayload = Pick; interface AttachmentElectronApi { getAppDataPath?: () => Promise; fileExists?: (filePath: string) => Promise; readFile?: (filePath: string) => Promise; deleteFile?: (filePath: string) => Promise; ensureDir?: (dirPath: string) => Promise; writeFile?: (filePath: string, data: string) => Promise; } type ElectronWindow = Window & { electronAPI?: AttachmentElectronApi; }; type LocalFileWithPath = File & { path?: string; }; /** * Manages peer-to-peer file transfer, local persistence, and * in-memory caching of file attachments linked to chat messages. * * Files are announced to peers via a `file-announce` event and * transferred using a chunked base-64 protocol over WebRTC data * channels. On Electron, files under {@link MAX_AUTO_SAVE_SIZE_BYTES} * are automatically persisted to the app-data directory. */ @Injectable({ providedIn: 'root' }) export class AttachmentService { private readonly webrtc = inject(WebRTCService); private readonly ngrxStore = inject(Store); private readonly database = inject(DatabaseService); private readonly router = inject(Router); /** Primary index: `messageId → Attachment[]`. */ private attachmentsByMessage = new Map(); /** Runtime cache of `messageId → roomId` for attachment gating. */ private messageRoomIds = new Map(); /** Room currently being watched in the router, or `null` outside room routes. */ private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); /** Incremented on every mutation so signal consumers re-render. */ updated = signal(0); /** * In-memory map of original `File` objects retained by the uploader * so that file-request handlers can stream them on demand. * Key format: `"messageId:fileId"`. */ private originalFiles = new Map(); /** Set of `"messageId:fileId:peerId"` keys representing cancelled transfers. */ private cancelledTransfers = new Set(); /** * Map of `"messageId:fileId" → Set` tracking which peers * have already been asked for a particular file. */ private pendingRequests = new Map>(); /** * In-flight chunk assembly buffers. * `"messageId:fileId" → ArrayBuffer[]` (indexed by chunk ordinal). */ private chunkBuffers = new Map(); /** * Number of chunks received for each in-flight transfer. * `"messageId:fileId" → number`. */ private chunkCounts = new Map(); /** Whether the initial DB load has been performed. */ private isDatabaseInitialised = false; constructor() { effect(() => { if (this.database.isReady() && !this.isDatabaseInitialised) { this.isDatabaseInitialised = true; this.initFromDatabase(); } }); this.router.events.subscribe((event) => { if (!(event instanceof NavigationEnd)) { return; } this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url); if (this.watchedRoomId) { void this.requestAutoDownloadsForRoom(this.watchedRoomId); } }); this.webrtc.onPeerConnected.subscribe(() => { if (this.watchedRoomId) { void this.requestAutoDownloadsForRoom(this.watchedRoomId); } }); } private getElectronApi(): AttachmentElectronApi | undefined { return (window as ElectronWindow).electronAPI; } /** Return the attachment list for a given message. */ getForMessage(messageId: string): Attachment[] { return this.attachmentsByMessage.get(messageId) ?? []; } /** Cache the room that owns a message so background downloads can be gated by the watched server. */ rememberMessageRoom(messageId: string, roomId: string): void { if (!messageId || !roomId) return; this.messageRoomIds.set(messageId, roomId); } /** Queue best-effort auto-download checks for a message's eligible attachments. */ queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void { void this.requestAutoDownloadsForMessage(messageId, attachmentId); } /** Auto-request eligible missing attachments for the currently watched room. */ async requestAutoDownloadsForRoom(roomId: string): Promise { if (!roomId || !this.isRoomWatched(roomId)) return; if (this.database.isReady()) { const messages = await this.database.getMessages(roomId, 500, 0); for (const message of messages) { this.rememberMessageRoom(message.id, message.roomId); await this.requestAutoDownloadsForMessage(message.id); } return; } for (const [messageId] of this.attachmentsByMessage) { const attachmentRoomId = await this.resolveMessageRoomId(messageId); if (attachmentRoomId === roomId) { await this.requestAutoDownloadsForMessage(messageId); } } } /** Remove every attachment associated with a message. */ async deleteForMessage(messageId: string): Promise { const attachments = this.attachmentsByMessage.get(messageId) ?? []; const hadCachedAttachments = attachments.length > 0 || this.attachmentsByMessage.has(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.attachmentsByMessage.delete(messageId); this.messageRoomIds.delete(messageId); this.clearMessageScopedState(messageId); if (hadCachedAttachments) { this.touch(); } if (this.database.isReady()) { await this.database.deleteAttachmentsForMessage(messageId); } for (const diskPath of savedPathsToDelete) { await this.deleteSavedFile(diskPath); } } /** * Build a map of minimal attachment metadata for a set of message IDs. * Used during inventory-based message synchronisation so that peers * learn about attachments without transferring file content. * * @param messageIds - Messages to collect metadata for. * @returns Record keyed by messageId whose values are arrays of * {@link AttachmentMeta} (local paths are scrubbed). */ getAttachmentMetasForMessages( messageIds: string[] ): Record { const result: Record = {}; for (const messageId of messageIds) { const attachments = this.attachmentsByMessage.get(messageId); if (attachments && attachments.length > 0) { result[messageId] = attachments.map((attachment) => ({ id: attachment.id, messageId: attachment.messageId, filename: attachment.filename, size: attachment.size, mime: attachment.mime, isImage: attachment.isImage, uploaderPeerId: attachment.uploaderPeerId, filePath: undefined, // never share local paths savedPath: undefined // never share local paths })); } } return result; } /** * Register attachment metadata received via message sync * (content is not yet available - only metadata). * * @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer. */ registerSyncedAttachments( attachmentMap: Record, messageRoomIds?: Record ): void { if (messageRoomIds) { for (const [messageId, roomId] of Object.entries(messageRoomIds)) { this.rememberMessageRoom(messageId, roomId); } } const newAttachments: Attachment[] = []; for (const [messageId, metas] of Object.entries(attachmentMap)) { const existing = this.attachmentsByMessage.get(messageId) ?? []; for (const meta of metas) { const alreadyKnown = existing.find((entry) => entry.id === meta.id); if (!alreadyKnown) { const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 }; existing.push(attachment); newAttachments.push(attachment); } } if (existing.length > 0) { this.attachmentsByMessage.set(messageId, existing); } } if (newAttachments.length > 0) { this.touch(); for (const attachment of newAttachments) { void this.persistAttachmentMeta(attachment); this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id); } } } /** * Request a file from any connected peer that might have it. * Automatically cycles through all connected peers if the first * one does not have the file. * * @param messageId - Parent message. * @param attachment - Attachment to request. */ requestFromAnyPeer(messageId: string, attachment: Attachment): void { const clearedRequestError = this.clearAttachmentRequestError(attachment); const connectedPeers = this.webrtc.getConnectedPeers(); if (connectedPeers.length === 0) { attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR; this.touch(); console.warn('[Attachments] No connected peers to request file from'); return; } if (clearedRequestError) this.touch(); const requestKey = this.buildRequestKey(messageId, attachment.id); this.pendingRequests.set(requestKey, new Set()); this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId); } /** * Handle a `file-not-found` response - try the next available peer. */ handleFileNotFound(payload: FileNotFoundPayload): void { const { messageId, fileId } = payload; if (!messageId || !fileId) return; const attachments = this.attachmentsByMessage.get(messageId) ?? []; const attachment = attachments.find((entry) => entry.id === fileId); const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId); if (!didSendRequest && attachment) { attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR; this.touch(); } } /** * Alias for {@link requestFromAnyPeer}. * Convenience wrapper for image-specific call-sites. */ requestImageFromAnyPeer(messageId: string, attachment: Attachment): void { this.requestFromAnyPeer(messageId, attachment); } /** Alias for {@link requestFromAnyPeer}. */ requestFile(messageId: string, attachment: Attachment): void { this.requestFromAnyPeer(messageId, attachment); } /** * Announce and optionally stream files attached to a newly sent * message to all connected peers. * * 1. Each file is assigned a UUID. * 2. A `file-announce` event is broadcast to peers. * 3. Peers watching the message's server can request any * auto-download-eligible media on demand. * * @param messageId - ID of the parent message. * @param files - Array of user-selected `File` objects. * @param uploaderPeerId - Peer ID of the uploader (used by receivers * to prefer the original source when requesting content). */ async publishAttachments( messageId: string, files: File[], uploaderPeerId?: string ): Promise { const attachments: Attachment[] = []; for (const file of files) { const fileId = uuidv4(); const attachment: Attachment = { id: fileId, messageId, filename: file.name, size: file.size, mime: file.type || DEFAULT_MIME_TYPE, isImage: file.type.startsWith('image/'), uploaderPeerId, filePath: (file as LocalFileWithPath).path, available: false }; attachments.push(attachment); // Retain the original File so we can serve file-request later this.originalFiles.set(`${messageId}:${fileId}`, file); // Make the file immediately visible to the uploader try { attachment.objectUrl = URL.createObjectURL(file); attachment.available = true; } catch { /* non-critical */ } // Auto-save small files to Electron disk cache if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { void this.saveFileToDisk(attachment, file); } // Broadcast metadata to peers const fileAnnounceEvent: FileAnnounceEvent = { type: 'file-announce', messageId, file: { id: fileId, filename: attachment.filename, size: attachment.size, mime: attachment.mime, isImage: attachment.isImage, uploaderPeerId } }; this.webrtc.broadcastMessage(fileAnnounceEvent); } const existingList = this.attachmentsByMessage.get(messageId) ?? []; this.attachmentsByMessage.set(messageId, [...existingList, ...attachments]); this.touch(); for (const attachment of attachments) { void this.persistAttachmentMeta(attachment); } } /** Handle a `file-announce` event from a peer. */ handleFileAnnounce(payload: FileAnnouncePayload): void { const { messageId, file } = payload; if (!messageId || !file) return; const list = this.attachmentsByMessage.get(messageId) ?? []; const alreadyKnown = list.find((entry) => entry.id === file.id); if (alreadyKnown) return; const attachment: Attachment = { id: file.id, messageId, filename: file.filename, size: file.size, mime: file.mime, isImage: !!file.isImage, uploaderPeerId: file.uploaderPeerId, available: false, receivedBytes: 0 }; list.push(attachment); this.attachmentsByMessage.set(messageId, list); this.touch(); void this.persistAttachmentMeta(attachment); this.queueAutoDownloadsForMessage(messageId, attachment.id); } /** * Handle an incoming `file-chunk` event. * * Chunks are collected in {@link chunkBuffers} until the total * expected count is reached, at which point the buffers are * assembled into a Blob and an object URL is created. */ handleFileChunk(payload: FileChunkPayload): void { const { messageId, fileId, fromPeerId, index, total, data } = payload; if ( !messageId || !fileId || typeof index !== 'number' || typeof total !== 'number' || typeof data !== 'string' ) return; const list = this.attachmentsByMessage.get(messageId) ?? []; const attachment = list.find((entry) => entry.id === fileId); if (!attachment) return; const decodedBytes = this.base64ToUint8Array(data); const assemblyKey = `${messageId}:${fileId}`; const requestKey = this.buildRequestKey(messageId, fileId); this.pendingRequests.delete(requestKey); this.clearAttachmentRequestError(attachment); // Initialise assembly buffer on first chunk let chunkBuffer = this.chunkBuffers.get(assemblyKey); if (!chunkBuffer) { chunkBuffer = new Array(total); this.chunkBuffers.set(assemblyKey, chunkBuffer); this.chunkCounts.set(assemblyKey, 0); } // Store the chunk (idempotent: ignore duplicate indices) if (!chunkBuffer[index]) { chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer; this.chunkCounts.set(assemblyKey, (this.chunkCounts.get(assemblyKey) ?? 0) + 1); } // Update progress stats const now = Date.now(); const previousReceived = attachment.receivedBytes ?? 0; attachment.receivedBytes = previousReceived + decodedBytes.byteLength; if (fromPeerId) recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now); if (!attachment.startedAtMs) attachment.startedAtMs = now; if (!attachment.lastUpdateMs) attachment.lastUpdateMs = now; const elapsedMs = Math.max(1, now - attachment.lastUpdateMs); const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000; const previousSpeed = attachment.speedBps ?? instantaneousBps; attachment.speedBps = EWMA_PREVIOUS_WEIGHT * previousSpeed + EWMA_CURRENT_WEIGHT * instantaneousBps; attachment.lastUpdateMs = now; this.touch(); // trigger UI update for progress bars // Check if assembly is complete const receivedChunkCount = this.chunkCounts.get(assemblyKey) ?? 0; if (receivedChunkCount === total || (attachment.receivedBytes ?? 0) >= attachment.size) { const completeBuffer = this.chunkBuffers.get(assemblyKey); if (completeBuffer && completeBuffer.every((part) => part instanceof ArrayBuffer)) { const blob = new Blob(completeBuffer, { type: attachment.mime }); attachment.available = true; attachment.objectUrl = URL.createObjectURL(blob); if (this.shouldPersistDownloadedAttachment(attachment)) { void this.saveFileToDisk(attachment, blob); } // Clean up assembly state this.chunkBuffers.delete(assemblyKey); this.chunkCounts.delete(assemblyKey); this.touch(); void this.persistAttachmentMeta(attachment); } } } /** * Handle an incoming `file-request` from a peer by streaming the * file content if available locally. * * Lookup order: * 1. In-memory original (`originalFiles` map). * 2. Electron `filePath` (uploader's original on disk). * 3. Electron `savedPath` (disk-cache copy). * 4. Electron disk-cache by room name (backward compat). * 5. In-memory object-URL blob (browser fallback). * * If none of these sources has the file, a `file-not-found` * message is sent so the requester can try another peer. */ async handleFileRequest(payload: FileRequestPayload): Promise { const { messageId, fileId, fromPeerId } = payload; if (!messageId || !fileId || !fromPeerId) return; // 1. In-memory original const exactKey = `${messageId}:${fileId}`; let originalFile = this.originalFiles.get(exactKey); // 1b. Fallback: search by fileId suffix (handles rare messageId drift) if (!originalFile) { for (const [key, file] of this.originalFiles) { if (key.endsWith(`:${fileId}`)) { originalFile = file; break; } } } if (originalFile) { await this.streamFileToPeer(fromPeerId, messageId, fileId, originalFile); return; } const list = this.attachmentsByMessage.get(messageId) ?? []; const attachment = list.find((entry) => entry.id === fileId); const electronApi = this.getElectronApi(); // 2. Electron filePath if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) { try { if (await electronApi.fileExists(attachment.filePath)) { await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.filePath); return; } } catch { /* fall through */ } } // 3. Electron savedPath if (attachment?.savedPath && electronApi?.fileExists && electronApi?.readFile) { try { if (await electronApi.fileExists(attachment.savedPath)) { await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.savedPath); return; } } catch { /* fall through */ } } // 3b. Disk cache by room name (backward compatibility) if (attachment?.isImage && electronApi?.getAppDataPath && electronApi?.fileExists && electronApi?.readFile) { try { const appDataPath = await electronApi.getAppDataPath(); if (appDataPath) { const roomName = await this.resolveCurrentRoomName(); const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; const diskPath = `${appDataPath}/server/${sanitisedRoom}/image/${attachment.filename}`; if (await electronApi.fileExists(diskPath)) { await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, diskPath); return; } } } catch { /* fall through */ } } // 4. In-memory blob if (attachment?.available && attachment.objectUrl) { try { const response = await fetch(attachment.objectUrl); const blob = await response.blob(); const file = new File([blob], attachment.filename, { type: attachment.mime }); await this.streamFileToPeer(fromPeerId, messageId, fileId, file); return; } catch { /* fall through */ } } // 5. File not available locally const fileNotFoundEvent: FileNotFoundEvent = { type: 'file-not-found', messageId, fileId }; this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent); } /** * Cancel an in-progress download from the requester side. * Resets local assembly state and notifies the uploader to stop. */ cancelRequest(messageId: string, attachment: Attachment): void { const targetPeerId = attachment.uploaderPeerId; if (!targetPeerId) return; try { // Reset assembly state const assemblyKey = `${messageId}:${attachment.id}`; this.chunkBuffers.delete(assemblyKey); this.chunkCounts.delete(assemblyKey); attachment.receivedBytes = 0; attachment.speedBps = 0; attachment.startedAtMs = undefined; attachment.lastUpdateMs = undefined; if (attachment.objectUrl) { try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } attachment.objectUrl = undefined; } attachment.available = false; this.touch(); // Notify uploader to stop streaming const fileCancelEvent: FileCancelEvent = { type: 'file-cancel', messageId, fileId: attachment.id }; this.webrtc.sendToPeer(targetPeerId, fileCancelEvent); } catch { /* best-effort */ } } /** * Handle a `file-cancel` from the requester - record the * cancellation so the streaming loop breaks early. */ handleFileCancel(payload: FileCancelPayload): void { const { messageId, fileId, fromPeerId } = payload; if (!messageId || !fileId || !fromPeerId) return; this.cancelledTransfers.add( this.buildTransferKey(messageId, fileId, fromPeerId) ); } /** * Provide a `File` for a pending request (uploader side) and * stream it to the requesting peer. */ async fulfillRequestWithFile( messageId: string, fileId: string, targetPeerId: string, file: File ): Promise { this.originalFiles.set(`${messageId}:${fileId}`, file); await this.streamFileToPeer(targetPeerId, messageId, fileId, file); } /** Bump the reactive update counter so signal-based consumers re-render. */ private touch(): void { this.updated.set(this.updated() + 1); } /** Composite key for transfer-cancellation tracking. */ private buildTransferKey(messageId: string, fileId: string, peerId: string): string { return `${messageId}:${fileId}:${peerId}`; } /** Composite key for pending-request tracking. */ private buildRequestKey(messageId: string, fileId: string): string { return `${messageId}:${fileId}`; } private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise { if (!messageId) return; const roomId = await this.resolveMessageRoomId(messageId); if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) { return; } const attachments = this.attachmentsByMessage.get(messageId) ?? []; for (const attachment of attachments) { if (attachmentId && attachment.id !== attachmentId) continue; if (!this.shouldAutoRequestWhenWatched(attachment)) continue; if (attachment.available) continue; if ((attachment.receivedBytes ?? 0) > 0) continue; if (this.pendingRequests.has(this.buildRequestKey(messageId, attachment.id))) continue; this.requestFromAnyPeer(messageId, attachment); } } private clearMessageScopedState(messageId: string): void { const scopedPrefix = `${messageId}:`; for (const key of Array.from(this.originalFiles.keys())) { if (key.startsWith(scopedPrefix)) { this.originalFiles.delete(key); } } for (const key of Array.from(this.pendingRequests.keys())) { if (key.startsWith(scopedPrefix)) { this.pendingRequests.delete(key); } } for (const key of Array.from(this.chunkBuffers.keys())) { if (key.startsWith(scopedPrefix)) { this.chunkBuffers.delete(key); } } for (const key of Array.from(this.chunkCounts.keys())) { if (key.startsWith(scopedPrefix)) { this.chunkCounts.delete(key); } } for (const key of Array.from(this.cancelledTransfers)) { if (key.startsWith(scopedPrefix)) { this.cancelledTransfers.delete(key); } } } private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise> { const retainedSavedPaths = new Set(); for (const [existingMessageId, attachments] of this.attachmentsByMessage) { 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 async deleteSavedFile(filePath: string): Promise { const electronApi = this.getElectronApi(); if (!electronApi?.deleteFile) return; await electronApi.deleteFile(filePath); } /** Clear any user-facing request error stored on an attachment. */ private clearAttachmentRequestError(attachment: Attachment): boolean { if (!attachment.requestError) return false; attachment.requestError = undefined; return true; } /** Check whether a specific transfer has been cancelled. */ private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean { return this.cancelledTransfers.has( this.buildTransferKey(messageId, fileId, targetPeerId) ); } /** Check whether a file is inline-previewable media. */ private isMedia(attachment: { mime: string }): boolean { return attachment.mime.startsWith('image/') || attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/'); } /** Auto-download only the assets that already supported eager loading when watched. */ private shouldAutoRequestWhenWatched(attachment: Attachment): boolean { return attachment.isImage || (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES); } /** Check whether a completed download should be cached on disk. */ private shouldPersistDownloadedAttachment(attachment: Attachment): boolean { return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES || attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/'); } /** * Send a `file-request` to the best untried peer. * @returns `true` if a request was dispatched. */ private sendFileRequestToNextPeer( messageId: string, fileId: string, preferredPeerId?: string ): boolean { const connectedPeers = this.webrtc.getConnectedPeers(); const requestKey = this.buildRequestKey(messageId, fileId); const triedPeers = this.pendingRequests.get(requestKey) ?? new Set(); // Pick the best untried peer: preferred first, then any let targetPeerId: string | undefined; if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) { targetPeerId = preferredPeerId; } else { targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId)); } if (!targetPeerId) { this.pendingRequests.delete(requestKey); return false; } triedPeers.add(targetPeerId); this.pendingRequests.set(requestKey, triedPeers); const fileRequestEvent: FileRequestEvent = { type: 'file-request', messageId, fileId }; this.webrtc.sendToPeer(targetPeerId, fileRequestEvent); return true; } /** Broadcast a file in base-64 chunks to all connected peers. */ private async streamFileToPeers( messageId: string, fileId: string, file: File ): Promise { const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); let offset = 0; let chunkIndex = 0; while (offset < file.size) { const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const arrayBuffer = await slice.arrayBuffer(); const base64 = this.arrayBufferToBase64(arrayBuffer); const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, fileId, index: chunkIndex, total: totalChunks, data: base64 }; this.webrtc.broadcastMessage(fileChunkEvent); offset += FILE_CHUNK_SIZE_BYTES; chunkIndex++; } } /** Stream a file in base-64 chunks to a single peer. */ private async streamFileToPeer( targetPeerId: string, messageId: string, fileId: string, file: File ): Promise { const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); let offset = 0; let chunkIndex = 0; while (offset < file.size) { if (this.isTransferCancelled(targetPeerId, messageId, fileId)) break; const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const arrayBuffer = await slice.arrayBuffer(); const base64 = this.arrayBufferToBase64(arrayBuffer); const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, fileId, index: chunkIndex, total: totalChunks, data: base64 }; await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); offset += FILE_CHUNK_SIZE_BYTES; chunkIndex++; } } /** * Read a file from Electron disk and stream it to a peer as * base-64 chunks. */ private async streamFileFromDiskToPeer( targetPeerId: string, messageId: string, fileId: string, diskPath: string ): Promise { const electronApi = this.getElectronApi(); if (!electronApi?.readFile) return; const base64Full = await electronApi.readFile(diskPath); const fileBytes = this.base64ToUint8Array(base64Full); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { if (this.isTransferCancelled(targetPeerId, messageId, fileId)) break; const start = chunkIndex * FILE_CHUNK_SIZE_BYTES; const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES); const slice = fileBytes.subarray(start, end); const sliceBuffer = (slice.buffer as ArrayBuffer).slice( slice.byteOffset, slice.byteOffset + slice.byteLength ); const base64Chunk = this.arrayBufferToBase64(sliceBuffer); const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, fileId, index: chunkIndex, total: totalChunks, data: base64Chunk }; this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); } } /** * Save a file to the Electron app-data directory, organised by * room name and media type. */ private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise { try { const electronApi = this.getElectronApi(); const appDataPath: string | undefined = await electronApi?.getAppDataPath?.(); if (!appDataPath || !electronApi?.ensureDir || !electronApi.writeFile) return; const roomName = await this.resolveCurrentRoomName(); const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; const subDirectory = attachment.mime.startsWith('video/') ? 'video' : attachment.mime.startsWith('audio/') ? 'audio' : attachment.mime.startsWith('image/') ? 'image' : 'files'; const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`; await electronApi.ensureDir(directoryPath); const arrayBuffer = await blob.arrayBuffer(); const base64 = this.arrayBufferToBase64(arrayBuffer); const diskPath = `${directoryPath}/${attachment.filename}`; await electronApi.writeFile(diskPath, base64); attachment.savedPath = diskPath; void this.persistAttachmentMeta(attachment); } catch { /* disk save is best-effort */ } } /** On startup, try loading previously saved files from disk (Electron). */ private async tryLoadSavedFiles(): Promise { const electronApi = this.getElectronApi(); if (!electronApi?.fileExists || !electronApi?.readFile) return; try { let hasChanges = false; for (const [, attachments] of this.attachmentsByMessage) { for (const attachment of attachments) { if (attachment.available) continue; // 1. Try savedPath (disk cache) if (attachment.savedPath) { try { if (await electronApi.fileExists(attachment.savedPath)) { this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.savedPath)); hasChanges = true; continue; } } catch { /* fall through */ } } // 2. Try filePath (uploader's original) if (attachment.filePath) { try { if (await electronApi.fileExists(attachment.filePath)) { this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.filePath)); hasChanges = true; if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { const response = await fetch(attachment.objectUrl!); void this.saveFileToDisk(attachment, await response.blob()); } continue; } } catch { /* fall through */ } } } } if (hasChanges) this.touch(); } catch { /* startup load is best-effort */ } } /** * Helper: decode a base-64 string from disk, create blob + object URL, * and populate the `originalFiles` map for serving file requests. */ 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; const file = new File([blob], attachment.filename, { type: attachment.mime }); this.originalFiles.set(`${attachment.messageId}:${attachment.id}`, file); } /** Save attachment metadata to the database (without file content). */ private 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 */ } } /** Load all attachment metadata from the database. */ 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.attachmentsByMessage = grouped; this.touch(); } catch { /* load is best-effort */ } } private extractWatchedRoomId(url: string): string | null { const roomMatch = url.match(ROOM_URL_PATTERN); return roomMatch ? roomMatch[1] : null; } private isRoomWatched(roomId: string | null | undefined): boolean { return !!roomId && roomId === this.watchedRoomId; } private async resolveMessageRoomId(messageId: string): Promise { const cachedRoomId = this.messageRoomIds.get(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.rememberMessageRoom(messageId, message.roomId); return message.roomId; } catch { return null; } } /** One-time migration from localStorage to the database. */ private async migrateFromLocalStorage(): Promise { try { const raw = localStorage.getItem(LEGACY_STORAGE_KEY); if (!raw) return; const legacyRecords: AttachmentMeta[] = JSON.parse(raw); for (const meta of legacyRecords) { const existing = this.attachmentsByMessage.get(meta.messageId) ?? []; if (!existing.find((entry) => entry.id === meta.id)) { const attachment: Attachment = { ...meta, available: false }; existing.push(attachment); this.attachmentsByMessage.set(meta.messageId, existing); void this.persistAttachmentMeta(attachment); } } localStorage.removeItem(LEGACY_STORAGE_KEY); this.touch(); } catch { /* migration is best-effort */ } } /** Full initialisation sequence: load DB → migrate → restore files. */ private async initFromDatabase(): Promise { await this.loadFromDatabase(); await this.migrateFromLocalStorage(); await this.tryLoadSavedFiles(); } /** Resolve the display name of the current room via the NgRx store. */ private resolveCurrentRoomName(): Promise { return new Promise((resolve) => { this.ngrxStore .select(selectCurrentRoomName) .pipe(take(1)) .subscribe((name) => resolve(name || '')); }); } /** Convert an ArrayBuffer to a base-64 string. */ private arrayBufferToBase64(buffer: ArrayBuffer): string { let binary = ''; const bytes = new Uint8Array(buffer); for (let index = 0; index < bytes.byteLength; index++) { binary += String.fromCharCode(bytes[index]); } return btoa(binary); } /** Convert a base-64 string to a Uint8Array. */ 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; } }