feat: Add deafen to pc, fix mobiel view, fix freeze on startup
This commit is contained in:
@@ -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']> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user