feat: Add deafen to pc, fix mobiel view, fix freeze on startup

This commit is contained in:
2026-06-05 15:27:06 +02:00
parent 35f52b0356
commit a675f12e61
85 changed files with 2499 additions and 519 deletions

View File

@@ -69,6 +69,12 @@ export class AttachmentFacade {
return this.manager.requestImageFromAnyPeer(...args);
}
tryRestoreAttachmentFromLocal(
...args: Parameters<AttachmentManagerService['tryRestoreAttachmentFromLocal']>
): ReturnType<AttachmentManagerService['tryRestoreAttachmentFromLocal']> {
return this.manager.tryRestoreAttachmentFromLocal(...args);
}
requestFile(
...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> {

View File

@@ -6,6 +6,7 @@ import {
import { NavigationEnd, Router } from '@angular/router';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
@@ -141,6 +142,16 @@ export class AttachmentManagerService {
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
}
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
const restored = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
if (restored) {
this.runtimeStore.touch();
}
return restored;
}
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment);
}
@@ -194,12 +205,14 @@ export class AttachmentManagerService {
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;
await yieldToAttachmentHydrationLoop();
}
}
}

View File

@@ -0,0 +1,115 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
describe('AttachmentPersistenceService', () => {
let database: {
isReady: ReturnType<typeof signal<boolean>>;
getAllAttachments: ReturnType<typeof vi.fn>;
getMessageById: ReturnType<typeof vi.fn>;
saveAttachment: ReturnType<typeof vi.fn>;
deleteAttachmentsForMessage: ReturnType<typeof vi.fn>;
};
let attachmentStorage: {
resolveExistingPath: ReturnType<typeof vi.fn>;
resolveCanonicalStoredPath: ReturnType<typeof vi.fn>;
readFile: ReturnType<typeof vi.fn>;
readFileChunk: ReturnType<typeof vi.fn>;
getFileSize: ReturnType<typeof vi.fn>;
canReadFileChunks: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
database = {
isReady: signal(true),
getAllAttachments: vi.fn(() => Promise.resolve([
{
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 1_500_000,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png'
}
])),
getMessageById: vi.fn(() => Promise.resolve(null)),
saveAttachment: vi.fn(() => Promise.resolve()),
deleteAttachmentsForMessage: vi.fn(() => Promise.resolve())
};
attachmentStorage = {
resolveExistingPath: vi.fn(() => Promise.resolve('/appdata/photo.png')),
resolveCanonicalStoredPath: vi.fn(() => Promise.resolve(null)),
readFile: vi.fn(() => Promise.resolve('QUJD')),
readFileChunk: vi.fn(() => Promise.resolve('QUJD')),
getFileSize: vi.fn(() => Promise.resolve(3)),
canReadFileChunks: vi.fn(() => true)
};
});
function createService(): AttachmentPersistenceService {
const injector = Injector.create({
providers: [
AttachmentPersistenceService,
AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => ({ pipe: () => ({ subscribe: () => {} }) }) } }
]
});
return runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
}
it('loads attachment metadata at startup without eagerly hydrating blobs from disk', async () => {
const service = createService();
await service.initFromDatabase();
expect(database.getAllAttachments).toHaveBeenCalledTimes(1);
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(attachmentStorage.getFileSize).not.toHaveBeenCalled();
});
it('hydrates blob URLs on demand for a single attachment', async () => {
const service = createService();
await service.initFromDatabase();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: false
};
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/);
expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png');
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,11 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
import {
ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES,
decodeBase64ToUint8Array,
yieldToAttachmentHydrationLoop
} from '../../domain/logic/attachment-blob.rules';
import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@@ -144,15 +149,11 @@ export class AttachmentPersistenceService {
return false;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
this.revokeAttachmentObjectUrl(attachment);
this.restoreAttachmentFromDisk(attachment, base64);
return true;
const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath);
return restored;
}
async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise<string | null> {
@@ -263,30 +264,54 @@ export class AttachmentPersistenceService {
private async runInitFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
private async tryLoadSavedFiles(): Promise<void> {
try {
let hasChanges = false;
private async restoreAttachmentBlobFromDiskPath(attachment: Attachment, diskPath: string): Promise<boolean> {
if (this.attachmentStorage.canReadFileChunks()) {
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) {
if (await this.tryRestoreAttachmentFromLocal(attachment)) {
hasChanges = true;
}
if (!fileSize || fileSize < 1) {
return false;
}
const blobParts: Uint8Array[] = [];
for (let start = 0; start < fileSize; start += ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES) {
const end = Math.min(start + ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, fileSize);
const chunkBase64 = await this.attachmentStorage.readFileChunk(diskPath, start, end);
if (!chunkBase64) {
return false;
}
blobParts.push(decodeBase64ToUint8Array(chunkBase64));
if (end < fileSize) {
await yieldToAttachmentHydrationLoop();
}
}
if (hasChanges)
this.runtimeStore.touch();
} catch { /* startup load is best-effort */ }
this.applyAttachmentBlob(attachment, new Blob(blobParts as BlobPart[], { type: attachment.mime }));
return true;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
const bytes = decodeBase64ToUint8Array(base64);
this.applyAttachmentBlob(
attachment,
new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime })
);
return true;
}
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
const bytes = this.base64ToUint8Array(base64);
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
private applyAttachmentBlob(attachment: Attachment, blob: Blob): void {
attachment.objectUrl = URL.createObjectURL(blob);
attachment.available = true;
@@ -335,14 +360,4 @@ export class AttachmentPersistenceService {
return retainedSavedPaths;
}
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;
}
}

View File

@@ -6,6 +6,7 @@ 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 { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import {
@@ -208,7 +209,7 @@ export class AttachmentTransferService {
filename: file.name,
size: file.size,
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
isImage: file.type.startsWith('image/'),
isImage: resolvePublishAttachmentIsImage(file),
uploaderPeerId,
filePath: (file as LocalFileWithPath).path,
available: false
@@ -309,7 +310,11 @@ export class AttachmentTransferService {
filename: file.filename,
size: file.size,
mime: file.mime,
isImage: !!file.isImage,
isImage: isImageAttachment({
filename: file.filename,
isImage: !!file.isImage,
mime: file.mime
}),
uploaderPeerId: file.uploaderPeerId,
available: false,
receivedBytes: 0