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

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { decodeBase64ToUint8Array } from './attachment-blob.rules';
describe('attachment blob rules', () => {
it('decodes base64 payloads into byte arrays', () => {
const bytes = decodeBase64ToUint8Array('QUJD');
expect(Array.from(bytes)).toEqual([65, 66, 67]);
});
});

View File

@@ -0,0 +1,21 @@
/** Chunk size used when rebuilding attachment blobs from disk without blocking the UI thread. */
export const ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES = 256 * 1024;
/** Decode a base64 payload into bytes for Blob construction. */
export function decodeBase64ToUint8Array(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;
}
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
export function yieldToAttachmentHydrationLoop(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}

View File

@@ -0,0 +1,63 @@
import {
describe,
expect,
it
} from 'vitest';
import {
dedupeImageAttachmentsForDisplay,
hasImageFilename,
isImageAttachment,
isInlineDisplayableImage,
resolvePublishAttachmentIsImage
} from './attachment-image.rules';
describe('attachment-image rules', () => {
it('detects images from mime, flag, or filename extension', () => {
expect(isImageAttachment({
id: '1',
filename: 'logo.PNG',
mime: 'application/octet-stream',
isImage: false,
available: false
})).toBe(true);
expect(hasImageFilename('photo.jpeg')).toBe(true);
expect(resolvePublishAttachmentIsImage({ name: 'photo.png', type: '' })).toBe(true);
});
it('treats file protocol urls as not inline displayable', () => {
expect(isInlineDisplayableImage({
available: true,
objectUrl: 'file:///tmp/photo.png'
})).toBe(false);
expect(isInlineDisplayableImage({
available: true,
objectUrl: 'blob:http://localhost/abc'
})).toBe(true);
});
it('dedupes image attachments by filename and prefers displayable copies', () => {
const deduped = dedupeImageAttachmentsForDisplay([
{
id: 'a',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: false
},
{
id: 'b',
filename: 'photo.png',
mime: 'application/octet-stream',
isImage: false,
available: true,
objectUrl: 'blob:http://localhost/photo'
}
]);
expect(deduped).toHaveLength(1);
expect(deduped[0]?.id).toBe('b');
});
});

View File

@@ -0,0 +1,96 @@
import { needsBlobObjectUrlForInlineDisplay } from './attachment-display-url.rules';
const IMAGE_EXTENSIONS = new Set([
'.apng',
'.avif',
'.bmp',
'.gif',
'.heic',
'.heif',
'.jpg',
'.jpeg',
'.png',
'.svg',
'.webp'
]);
export interface ImageAttachmentCandidate {
available: boolean;
filename: string;
filePath?: string;
id: string;
isImage: boolean;
mime: string;
objectUrl?: string;
savedPath?: string;
}
export function hasImageFilename(filename: string): boolean {
const normalized = filename.trim().toLowerCase();
const extensionIndex = normalized.lastIndexOf('.');
if (extensionIndex <= 0) {
return false;
}
return IMAGE_EXTENSIONS.has(normalized.slice(extensionIndex));
}
export function isImageAttachment(attachment: Pick<ImageAttachmentCandidate, 'filename' | 'isImage' | 'mime'>): boolean {
return attachment.isImage ||
attachment.mime.startsWith('image/') ||
hasImageFilename(attachment.filename);
}
export function isInlineDisplayableImage(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'objectUrl'>
): boolean {
return attachment.available &&
!!attachment.objectUrl &&
!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl);
}
export function imageAttachmentDisplayRank(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'>
): number {
if (isInlineDisplayableImage(attachment)) {
return 4;
}
if (attachment.savedPath || attachment.filePath) {
return 3;
}
if (attachment.available && attachment.objectUrl) {
return 2;
}
if (attachment.isImage) {
return 1;
}
return 0;
}
export function dedupeImageAttachmentsForDisplay<T extends ImageAttachmentCandidate>(attachments: readonly T[]): T[] {
const byFilename = new Map<string, T>();
for (const attachment of attachments) {
if (!isImageAttachment(attachment)) {
continue;
}
const key = attachment.filename.trim().toLowerCase();
const existing = byFilename.get(key);
if (!existing || imageAttachmentDisplayRank(attachment) > imageAttachmentDisplayRank(existing)) {
byFilename.set(key, attachment);
}
}
return Array.from(byFilename.values());
}
export function resolvePublishAttachmentIsImage(file: Pick<File, 'name' | 'type'>): boolean {
return file.type.startsWith('image/') || hasImageFilename(file.name);
}