Files
Toju/src/app/core/services/attachment.service.ts
Myx 1cdd1c5d2b
Some checks failed
Queue Release Build / build-linux (push) Blocked by required conditions
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
fix typing indicator on wrong server
2026-03-18 22:10:11 +01:00

1382 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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<ChatEvent, 'messageId' | 'file'>;
interface FileChunkPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
total?: number;
data?: ChatEvent['data'];
}
type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
interface AttachmentElectronApi {
getAppDataPath?: () => Promise<string>;
fileExists?: (filePath: string) => Promise<boolean>;
readFile?: (filePath: string) => Promise<string>;
deleteFile?: (filePath: string) => Promise<boolean>;
ensureDir?: (dirPath: string) => Promise<boolean>;
writeFile?: (filePath: string, data: string) => Promise<boolean>;
}
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<string, Attachment[]>();
/** Runtime cache of `messageId → roomId` for attachment gating. */
private messageRoomIds = new Map<string, string>();
/** 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<number>(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<string, File>();
/** Set of `"messageId:fileId:peerId"` keys representing cancelled transfers. */
private cancelledTransfers = new Set<string>();
/**
* Map of `"messageId:fileId" → Set<peerId>` tracking which peers
* have already been asked for a particular file.
*/
private pendingRequests = new Map<string, Set<string>>();
/**
* In-flight chunk assembly buffers.
* `"messageId:fileId" → ArrayBuffer[]` (indexed by chunk ordinal).
*/
private chunkBuffers = new Map<string, ArrayBuffer[]>();
/**
* Number of chunks received for each in-flight transfer.
* `"messageId:fileId" → number`.
*/
private chunkCounts = new Map<string, number>();
/** 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<void> {
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<void> {
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<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.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<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {};
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<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): 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<void> {
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<void> {
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<void> {
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<void> {
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<Set<string>> {
const retainedSavedPaths = new Set<string>();
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<void> {
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<string>();
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<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 */ }
}
/** Load all attachment metadata from the database. */
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.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<string | null> {
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<void> {
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<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
/** Resolve the display name of the current room via the NgRx store. */
private resolveCurrentRoomName(): Promise<string> {
return new Promise<string>((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;
}
}