Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user