Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors

This commit is contained in:
2026-03-06 04:47:07 +01:00
parent 2d84fbd91a
commit fe2347b54e
65 changed files with 3593 additions and 1030 deletions

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
import {
Injectable,
inject,
@@ -14,8 +14,9 @@ import { DatabaseService } from './database.service';
/** 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 to disk (Electron). */
const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/** 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
@@ -27,6 +28,10 @@ const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT;
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.
@@ -69,6 +74,8 @@ export interface Attachment extends AttachmentMeta {
startedAtMs?: number;
/** Epoch ms of the most recent chunk received. */
lastUpdateMs?: number;
/** User-facing request failure shown in the attachment card. */
requestError?: string;
}
/**
@@ -222,13 +229,19 @@ export class AttachmentService {
* @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());
@@ -246,8 +259,12 @@ export class AttachmentService {
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
const attachment = attachments.find((entry) => entry.id === fileId);
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
if (!didSendRequest && attachment) {
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
this.touch();
}
}
/**
@@ -269,8 +286,8 @@ export class AttachmentService {
*
* 1. Each file is assigned a UUID.
* 2. A `file-announce` event is broadcast to peers.
* 3. Images ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES} are immediately
* streamed as chunked base-64.
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
* are immediately streamed as chunked base-64.
*
* @param messageId - ID of the parent message.
* @param files - Array of user-selected `File` objects.
@@ -328,8 +345,8 @@ export class AttachmentService {
}
} as any);
// Auto-stream small images
if (attachment.isImage && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
// Auto-stream small inline-preview media
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
await this.streamFileToPeers(messageId, fileId, file);
}
}
@@ -401,6 +418,10 @@ export class AttachmentService {
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);
@@ -453,7 +474,7 @@ export class AttachmentService {
attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob);
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
if (this.shouldPersistDownloadedAttachment(attachment)) {
void this.saveFileToDisk(attachment, blob);
}
@@ -652,6 +673,15 @@ export class AttachmentService {
return `${messageId}:${fileId}`;
}
/** 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(
@@ -659,9 +689,18 @@ export class AttachmentService {
);
}
/** Check whether a file is an image or video. */
/** Check whether a file is inline-previewable media. */
private isMedia(attachment: { mime: string }): boolean {
return attachment.mime.startsWith('image/') || attachment.mime.startsWith('video/');
return attachment.mime.startsWith('image/') ||
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}
/** 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/');
}
/**
@@ -822,9 +861,11 @@ export class AttachmentService {
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
const subDirectory = attachment.mime.startsWith('video/')
? 'video'
: attachment.mime.startsWith('image/')
? 'image'
: 'files';
: attachment.mime.startsWith('audio/')
? 'audio'
: attachment.mime.startsWith('image/')
? 'image'
: 'files';
const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`;
await electronApi.ensureDir(directoryPath);