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}`;
}