feat: Add emoji and alot of other fixes

This commit is contained in:
2026-06-05 05:40:18 +02:00
parent ca069e2f61
commit 6865147e8f
72 changed files with 3885 additions and 413 deletions

View File

@@ -44,7 +44,11 @@ export class AttachmentManagerService {
effect(() => {
if (this.database.isReady() && !this.isDatabaseInitialised) {
this.isDatabaseInitialised = true;
void this.persistence.initFromDatabase();
void this.persistence.initFromDatabase().then(() => {
if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
}
});
}
});
@@ -56,12 +60,14 @@ export class AttachmentManagerService {
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
this.webrtc.onPeerConnected.subscribe(() => {
if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
@@ -110,11 +116,11 @@ export class AttachmentManagerService {
return this.transfer.getAttachmentMetasForMessages(messageIds);
}
registerSyncedAttachments(
async registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
): Promise<void> {
await this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
for (const attachment of attachments) {
@@ -123,20 +129,20 @@ export class AttachmentManagerService {
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestFromAnyPeer(messageId, attachment);
requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFromAnyPeer(messageId, attachment);
}
handleFileNotFound(payload: FileNotFoundPayload): void {
this.transfer.handleFileNotFound(payload);
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestImageFromAnyPeer(messageId, attachment);
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.transfer.requestFile(messageId, attachment);
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment);
}
async publishAttachments(
@@ -180,11 +186,66 @@ export class AttachmentManagerService {
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
}
private async restoreLocalAttachmentsForRoom(roomId: string): Promise<void> {
if (!this.isRoomWatched(roomId)) {
return;
}
await this.persistence.whenReady();
const messageIds = await this.collectMessageIdsForRoom(roomId);
let hasChanges = false;
for (const messageId of messageIds) {
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
hasChanges = true;
}
}
}
if (hasChanges) {
this.runtimeStore.touch();
}
}
private async collectMessageIdsForRoom(roomId: string): Promise<string[]> {
if (isDirectMessageAttachmentRoomId(roomId)) {
const messageIds: string[] = [];
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
messageIds.push(messageId);
}
}
return messageIds;
}
if (!this.database.isReady()) {
return Array.from(this.runtimeStore.getAttachmentEntries())
.filter(([messageId]) => this.runtimeStore.getMessageRoomId(messageId) === roomId)
.map(([messageId]) => messageId);
}
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
}
return messages.map((message) => message.id);
}
private async runAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!this.isRoomWatched(roomId)) {
return;
}
await this.restoreLocalAttachmentsForRoom(roomId);
if (isDirectMessageAttachmentRoomId(roomId)) {
await this.requestAutoDownloadsForRuntimeRoom(roomId);
return;
@@ -242,7 +303,7 @@ export class AttachmentManagerService {
if (this.transfer.hasPendingRequest(messageId, attachment.id))
continue;
this.transfer.requestFromAnyPeer(messageId, attachment);
void this.transfer.requestFromAnyPeer(messageId, attachment);
}
}

View File

@@ -7,10 +7,13 @@ import { AttachmentStorageService } from '../../infrastructure/services/attachme
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' })
export class AttachmentPersistenceService {
private initPromise: Promise<void> | null = null;
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly ngrxStore = inject(Store);
private readonly attachmentStorage = inject(AttachmentStorageService);
@@ -51,11 +54,26 @@ export class AttachmentPersistenceService {
}
}
whenReady(): Promise<void> {
if (this.database.isReady()) {
return this.initFromDatabase();
}
return this.initPromise ?? Promise.resolve();
}
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
if (!this.database.isReady())
return;
try {
const storedRecords = await this.database.getAttachmentsForMessage(attachment.messageId);
const storedRecord = storedRecords.find((record) => record.id === attachment.id);
const localPaths = mergeAttachmentLocalPaths(attachment, storedRecord);
attachment.filePath = localPaths.filePath ?? undefined;
attachment.savedPath = localPaths.savedPath ?? undefined;
await this.database.saveAttachment({
id: attachment.id,
messageId: attachment.messageId,
@@ -64,8 +82,8 @@ export class AttachmentPersistenceService {
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId,
filePath: attachment.filePath,
savedPath: attachment.savedPath
filePath: localPaths.filePath ?? undefined,
savedPath: localPaths.savedPath ?? undefined
});
} catch { /* persistence is best-effort */ }
}
@@ -87,9 +105,73 @@ export class AttachmentPersistenceService {
}
async initFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
if (!this.initPromise) {
this.initPromise = this.runInitFromDatabase();
}
return this.initPromise;
}
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
if (attachment.available) {
return true;
}
let diskPath = await this.attachmentStorage.resolveExistingPath(attachment);
if (!diskPath) {
const roomName = await this.resolveStorageContainerName(attachment);
diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName);
if (diskPath) {
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
}
}
if (!diskPath) {
return false;
}
if (await this.restoreMediaAttachmentFromFileUrl(attachment, diskPath)) {
attachment.requestError = undefined;
return true;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
this.restoreAttachmentFromDisk(attachment, base64);
attachment.requestError = undefined;
return true;
}
async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise<string | null> {
try {
const storageContainer = await this.resolveStorageContainerName(attachment);
const diskPath = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
if (!diskPath) {
return null;
}
const copied = await this.attachmentStorage.copyFile(sourcePath, diskPath);
if (!copied) {
await this.attachmentStorage.deleteFile(diskPath);
return null;
}
attachment.savedPath = diskPath;
await this.persistAttachmentMeta(attachment);
return diskPath;
} catch {
return null;
}
}
async resolveMessageRoomId(messageId: string): Promise<string | null> {
@@ -173,50 +255,20 @@ export class AttachmentPersistenceService {
} catch { /* migration is best-effort */ }
}
private async runInitFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
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) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) {
hasChanges = true;
continue;
}
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
if (savedBase64) {
this.restoreAttachmentFromDisk(attachment, savedBase64);
hasChanges = true;
continue;
}
}
if (attachment.filePath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) {
hasChanges = true;
continue;
}
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 (await this.tryRestoreAttachmentFromLocal(attachment)) {
hasChanges = true;
}
}
}

View File

@@ -1,16 +1,20 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs';
import { Store } from '@ngrx/store';
import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime/logging/debug-network-metrics';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE,
FILE_NOT_FOUND_REQUEST_ERROR,
NO_CONNECTED_PEERS_REQUEST_ERROR
NO_CONNECTED_PEERS_REQUEST_ERROR,
UPLOADER_LOCAL_FILE_MISSING_ERROR
} from '../../domain/constants/attachment-transfer.constants';
import {
type FileAnnounceEvent,
@@ -46,6 +50,7 @@ interface ValidFileChunkPayload {
@Injectable({ providedIn: 'root' })
export class AttachmentTransferService {
private readonly ngrxStore = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly attachmentStorage = inject(AttachmentStorageService);
@@ -79,10 +84,12 @@ export class AttachmentTransferService {
return result;
}
registerSyncedAttachments(
async registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
): Promise<void> {
await this.persistence.whenReady();
if (messageRoomIds) {
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
this.runtimeStore.rememberMessageRoom(messageId, roomId);
@@ -119,12 +126,28 @@ export class AttachmentTransferService {
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
async requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
const clearedRequestError = this.clearAttachmentRequestError(attachment);
if (!attachment.available) {
const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
if (restoredLocally) {
this.runtimeStore.touch();
return;
}
}
const connectedPeers = this.webrtc.getConnectedPeers();
const currentUserId = await this.resolveCurrentUserId();
const isUploader = !!attachment.uploaderPeerId &&
!!currentUserId &&
attachment.uploaderPeerId === currentUserId;
if (connectedPeers.length === 0) {
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
attachment.requestError = isUploader
? UPLOADER_LOCAL_FILE_MISSING_ERROR
: NO_CONNECTED_PEERS_REQUEST_ERROR;
this.runtimeStore.touch();
console.warn('[Attachments] No connected peers to request file from');
return;
@@ -157,12 +180,12 @@ export class AttachmentTransferService {
}
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
return this.requestFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.requestFromAnyPeer(messageId, attachment);
}
hasPendingRequest(messageId: string, fileId: string): boolean {
@@ -209,6 +232,36 @@ export class AttachmentTransferService {
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
} else if (shouldCopyUploaderMediaToAppData(
attachment,
attachment.filePath,
this.attachmentStorage.canCopyFiles()
)) {
const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath!);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
} else if (
this.isPlayableMedia(attachment) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES &&
this.attachmentStorage.canWriteFiles()
) {
const savedPath = await this.persistence.saveFileToDisk(attachment, file);
if (savedPath) {
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true;
}
}
}
const fileAnnounceEvent: FileAnnounceEvent = {
@@ -471,6 +524,15 @@ export class AttachmentTransferService {
);
}
private resolveCurrentUserId(): Promise<string | null> {
return new Promise<string | null>((resolve) => {
this.ngrxStore
.select(selectCurrentUserId)
.pipe(take(1))
.subscribe((userId) => resolve(userId));
});
}
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
return `${messageId}:${fileId}:${peerId}`;
}

View File

@@ -18,3 +18,7 @@ export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are availabl
/** User-facing error when connected peers cannot provide a requested file. */
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
/** User-facing error when the uploader's local copy cannot be restored. */
export const UPLOADER_LOCAL_FILE_MISSING_ERROR =
'Your original upload could not be found on this device. Re-upload the file to restore playback.';

View File

@@ -0,0 +1,33 @@
import { mergeAttachmentLocalPaths } from './attachment-persistence.rules';
describe('attachment persistence rules', () => {
it('keeps incoming local paths when they are set', () => {
expect(mergeAttachmentLocalPaths(
{ filePath: '/tmp/new.mp4', savedPath: '/data/saved.mp4' },
{ filePath: '/tmp/old.mp4', savedPath: '/data/old.mp4' }
)).toEqual({
filePath: '/tmp/new.mp4',
savedPath: '/data/saved.mp4'
});
});
it('preserves stored local paths when incoming sync metadata omits them', () => {
expect(mergeAttachmentLocalPaths(
{},
{ filePath: '/home/ludde/video.mp4', savedPath: '/appdata/video.mp4' }
)).toEqual({
filePath: '/home/ludde/video.mp4',
savedPath: '/appdata/video.mp4'
});
});
it('does not overwrite a stored path with an explicit null', () => {
expect(mergeAttachmentLocalPaths(
{ filePath: null, savedPath: null },
{ filePath: '/home/ludde/video.mp4', savedPath: '/appdata/video.mp4' }
)).toEqual({
filePath: '/home/ludde/video.mp4',
savedPath: '/appdata/video.mp4'
});
});
});

View File

@@ -0,0 +1,26 @@
export interface AttachmentLocalPaths {
filePath?: string | null;
savedPath?: string | null;
}
export function mergeAttachmentLocalPaths(
incoming: AttachmentLocalPaths,
stored?: AttachmentLocalPaths | null
): Required<AttachmentLocalPaths> {
const resolvePath = (incomingPath: string | null | undefined, storedPath: string | null | undefined): string | null => {
if (typeof incomingPath === 'string' && incomingPath.trim()) {
return incomingPath;
}
if (typeof storedPath === 'string' && storedPath.trim()) {
return storedPath;
}
return null;
};
return {
filePath: resolvePath(incoming.filePath, stored?.filePath),
savedPath: resolvePath(incoming.savedPath, stored?.savedPath)
};
}

View File

@@ -1,4 +1,8 @@
import { getWatchedAttachmentRoomIdFromUrl, isDirectMessageAttachmentRoomId } from './attachment.logic';
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
shouldCopyUploaderMediaToAppData
} from './attachment.logic';
describe('attachment logic', () => {
it('extracts watched server room ids from room URLs', () => {
@@ -21,4 +25,23 @@ describe('attachment logic', () => {
expect(isDirectMessageAttachmentRoomId('room-1')).toBe(false);
expect(isDirectMessageAttachmentRoomId(null)).toBe(false);
});
it('copies large uploader media into app data when a source path exists', () => {
expect(shouldCopyUploaderMediaToAppData({
size: 64 * 1024 * 1024,
mime: 'video/mp4'
}, '/home/ludde/video.mp4', true)).toBe(true);
});
it('skips app-data copy for small uploads and missing source paths', () => {
expect(shouldCopyUploaderMediaToAppData({
size: 1024,
mime: 'video/mp4'
}, '/home/ludde/video.mp4', true)).toBe(false);
expect(shouldCopyUploaderMediaToAppData({
size: 64 * 1024 * 1024,
mime: 'video/mp4'
}, undefined, true)).toBe(false);
});
});

View File

@@ -22,6 +22,17 @@ export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, '
attachment.mime.startsWith('audio/');
}
export function shouldCopyUploaderMediaToAppData(
attachment: Pick<Attachment, 'size' | 'mime'>,
sourcePath?: string | null,
canCopyFiles = false
): boolean {
return canCopyFiles &&
!!sourcePath &&
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/')) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
}
export function getWatchedAttachmentRoomIdFromUrl(url: string): string | null {
const path = url.split(/[?#]/, 1)[0];
const directMessageMatch = path.match(DIRECT_MESSAGE_URL_PATTERN);

View File

@@ -0,0 +1,27 @@
import { annotateLocalFilePath, resolveLocalFilePath } from './local-file-path.rules';
describe('local file path rules', () => {
it('prefers an existing path property on the file', () => {
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
Object.defineProperty(file, 'path', { value: '/tmp/clip.mp4' });
expect(resolveLocalFilePath(file)).toBe('/tmp/clip.mp4');
});
it('resolves drag-and-drop files through Electron getPathForFile', () => {
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
expect(resolveLocalFilePath(file, {
getPathForFile: () => '/home/ludde/Videos/clip.mp4'
})).toBe('/home/ludde/Videos/clip.mp4');
});
it('annotates a file object with the resolved path', () => {
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
const annotated = annotateLocalFilePath(file, {
getPathForFile: () => '/home/ludde/Videos/clip.mp4'
});
expect(resolveLocalFilePath(annotated)).toBe('/home/ludde/Videos/clip.mp4');
});
});

View File

@@ -0,0 +1,54 @@
export type LocalFilePathResolver = (file: File) => string;
type LocalFileWithPath = File & {
path?: string;
};
export function resolveLocalFilePath(
file: File,
options?: {
getPathForFile?: LocalFilePathResolver;
}
): string | undefined {
const existingPath = (file as LocalFileWithPath).path?.trim();
if (existingPath) {
return existingPath;
}
if (!options?.getPathForFile) {
return undefined;
}
try {
const resolvedPath = options.getPathForFile(file).trim();
return resolvedPath || undefined;
} catch {
return undefined;
}
}
export function annotateLocalFilePath(
file: File,
options?: {
getPathForFile?: LocalFilePathResolver;
}
): File {
const resolvedPath = resolveLocalFilePath(file, options);
if (!resolvedPath) {
return file;
}
try {
Object.defineProperty(file, 'path', {
configurable: true,
value: resolvedPath
});
} catch {
(file as LocalFileWithPath).path = resolvedPath;
}
return file;
}

View File

@@ -1,3 +1,4 @@
export * from './application/facades/attachment.facade';
export * from './domain/constants/attachment.constants';
export * from './domain/logic/local-file-path.rules';
export * from './domain/models/attachment.model';

View File

@@ -25,10 +25,46 @@ export class AttachmentStorageService {
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
}
canCopyFiles(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.copyFile && !!electronApi.ensureDir && !!electronApi.getAppDataPath;
}
async resolveExistingPath(
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
): Promise<string | null> {
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
return this.findExistingPath([attachment.savedPath, attachment.filePath]);
}
async resolveCanonicalStoredPath(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
roomName: string
): Promise<string | null> {
const appDataPath = await this.resolveAppDataPath();
if (!appDataPath) {
return null;
}
const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
return this.findExistingPath([diskPath]);
}
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.copyFile || !sourceFilePath || !destinationFilePath) {
return false;
}
try {
return await electronApi.copyFile(sourceFilePath, destinationFilePath);
} catch {
return false;
}
}
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {