wip: optimizations

This commit is contained in:
2026-05-23 15:28:40 +02:00
parent 5bf506af03
commit 155fe20862
89 changed files with 7431 additions and 392 deletions

View File

@@ -1,5 +1,6 @@
import { STORAGE_KEY_GENERAL_SETTINGS, STORAGE_KEY_LAST_VIEWED_CHAT } from '../../core/constants';
import { getUserScopedStorageKey } from '../../core/storage/current-user-storage';
import { runWhenIdle } from '../../shared/rxjs';
export interface GeneralSettings {
reopenLastViewedChat: boolean;
@@ -15,10 +16,65 @@ export interface LastViewedChatSnapshot {
channelId: string | null;
}
const pendingWrites = new Map<string, string | null>();
let flushScheduled = false;
let cancelScheduledFlush: (() => void) | null = null;
function scheduleStorageFlush(): void {
if (flushScheduled) {
return;
}
flushScheduled = true;
cancelScheduledFlush = runWhenIdle(() => {
flushScheduled = false;
cancelScheduledFlush = null;
const snapshot = Array.from(pendingWrites.entries());
pendingWrites.clear();
for (const [key, value] of snapshot) {
try {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} catch {
// storage not available
}
}
});
}
function scheduleStorageWrite(key: string, serialised: string): void {
pendingWrites.set(key, serialised);
scheduleStorageFlush();
}
function scheduleStorageRemove(key: string): void {
pendingWrites.set(key, null);
scheduleStorageFlush();
}
function readMaybePending(key: string): string | null {
if (pendingWrites.has(key)) {
return pendingWrites.get(key) ?? null;
}
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function loadGeneralSettingsFromStorage(): GeneralSettings {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
?? localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
const raw = readMaybePending(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
?? readMaybePending(STORAGE_KEY_GENERAL_SETTINGS);
if (!raw) {
return { ...DEFAULT_GENERAL_SETTINGS };
@@ -36,17 +92,15 @@ export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): G
...patch
});
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS), JSON.stringify(nextSettings));
} catch {}
scheduleStorageWrite(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS), JSON.stringify(nextSettings));
return nextSettings;
}
export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
?? localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
const raw = readMaybePending(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
?? readMaybePending(STORAGE_KEY_LAST_VIEWED_CHAT);
if (!raw) {
return null;
@@ -75,16 +129,41 @@ export function saveLastViewedChatToStorage(snapshot: LastViewedChatSnapshot): v
return;
}
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, normalised.userId), JSON.stringify(normalised));
} catch {}
scheduleStorageWrite(
getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, normalised.userId),
JSON.stringify(normalised)
);
}
export function clearLastViewedChatFromStorage(userId?: string | null): void {
try {
localStorage.removeItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId));
localStorage.removeItem(STORAGE_KEY_LAST_VIEWED_CHAT);
} catch {}
scheduleStorageRemove(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId));
scheduleStorageRemove(STORAGE_KEY_LAST_VIEWED_CHAT);
}
/** Force-flush any pending app-resume writes (e.g. before unload). */
export function flushAppResumeStorage(): void {
if (cancelScheduledFlush) {
cancelScheduledFlush();
cancelScheduledFlush = null;
}
flushScheduled = false;
const snapshot = Array.from(pendingWrites.entries());
pendingWrites.clear();
for (const [key, value] of snapshot) {
try {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} catch {
// storage not available
}
}
}
function normaliseGeneralSettings(raw: Partial<GeneralSettings>): GeneralSettings {

View File

@@ -10,6 +10,7 @@ import {
} from '../../shared-kernel';
import type { ChatAttachmentMeta } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import type { RoomMessageStats } from './database.service';
/** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou';
@@ -110,6 +111,14 @@ export class BrowserDatabaseService {
return this.hydrateMessages(messages);
}
async getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
return this.foldMessagesForRoom(roomId, (stats, message) => {
stats.count += 1;
stats.lastUpdated = Math.max(stats.lastUpdated, message.editedAt || message.timestamp || 0);
}, { count: 0,
lastUpdated: 0 });
}
/** Delete a message by its ID. */
async deleteMessage(messageId: string): Promise<void> {
await this.deleteRecord(STORE_MESSAGES, messageId);
@@ -533,6 +542,36 @@ export class BrowserDatabaseService {
});
}
private async foldMessagesForRoom<T>(
roomId: string,
visit: (state: T, message: Message) => void,
initialState: T
): Promise<T> {
const transaction = this.createTransaction(STORE_MESSAGES, 'readonly');
const request = transaction.objectStore(STORE_MESSAGES)
.index('roomId')
.openCursor(IDBKeyRange.only(roomId));
return new Promise<T>((resolve, reject) => {
const state = initialState;
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(state);
return;
}
visit(state, cursor.value as Message);
cursor.continue();
};
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error);
});
}
private async deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
const transaction = this.createTransaction(storeName, 'readwrite');

View File

@@ -16,6 +16,11 @@ import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-database.service';
export interface RoomMessageStats {
count: number;
lastUpdated: number;
}
/**
* Facade database service that transparently delegates to the correct
* storage backend based on the runtime platform.
@@ -66,6 +71,9 @@ export class DatabaseService {
/** Retrieve messages newer than a given timestamp for a room. */
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.backend.getMessagesSince(roomId, sinceTimestamp); }
/** Retrieve aggregate message stats for sync handshakes without loading history. */
getRoomMessageStats(roomId: string) { return this.backend.getRoomMessageStats(roomId); }
/** Permanently delete a message by ID. */
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }

View File

@@ -8,6 +8,7 @@ import {
} from '../../shared-kernel';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
import type { RoomMessageStats } from './database.service';
/**
* Database service for the Electron (desktop) runtime.
@@ -63,6 +64,10 @@ export class ElectronDatabaseService {
return this.api.query<Message[]>({ type: 'get-messages-since', payload: { roomId, sinceTimestamp } });
}
getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
return this.api.query<RoomMessageStats>({ type: 'get-room-message-stats', payload: { roomId } });
}
/** Permanently delete a message by ID. */
deleteMessage(messageId: string): Promise<void> {
return this.api.command({ type: 'delete-message', payload: { messageId } });

View File

@@ -0,0 +1,204 @@
import { runWhenIdle } from '../../shared/rxjs/idle';
/**
* Detects environments where writes should bypass the idle queue
* and flush synchronously (unit tests, SSR, etc.). Browsers with a
* real document/window get the deferred path.
*/
function isSyncFlushEnvironment(): boolean {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return true;
}
// Vitest / Jest set NODE_ENV=test
const proc = (globalThis as unknown as { process?: { env?: Record<string, string> } }).process;
return proc?.env?.['NODE_ENV'] === 'test' || proc?.env?.['VITEST'] === 'true';
}
/**
* Coalesced-write wrapper around `localStorage`.
*
* - **Reads** are still synchronous (you typically need the value during
* service initialisation) but cached after the first hit so repeat
* parses do not block the render thread.
* - **Writes** are queued and flushed in a single `requestIdleCallback`
* (or `setTimeout(0)` fallback). Rapid signal updates (e.g. drag-resize,
* typing) coalesce into one `JSON.stringify` + write.
* - **Equality** uses a cheap reference / shallow compare before scheduling
* a flush so the heavy `JSON.stringify` only runs when the value really
* changed.
*
* This service exists so domain stores (plugins, themes, voice settings,
* server endpoints, friend list, ICE servers, etc.) can stop hand-rolling
* `JSON.parse` / `JSON.stringify` calls on the render path.
*/
export class JsonStorageService {
private readonly pendingWrites = new Map<string, unknown>();
private readonly subscribers = new Map<string, Set<(value: unknown) => void>>();
private flushScheduled = false;
private cancelScheduledFlush: (() => void) | null = null;
read<T>(key: string, fallback: T): T {
if (this.pendingWrites.has(key)) {
return this.pendingWrites.get(key) as T;
}
const raw = this.safeReadRaw(key);
if (raw === null) {
return fallback;
}
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
/**
* Queue a write. Multiple writes to the same key within the same idle
* window collapse to a single stringify + `localStorage.setItem`.
*
* Pass `equals` for a fast custom equality test (e.g. an `id` compare)
* to skip flushes when the payload is structurally unchanged.
*/
write<T>(key: string, value: T, equals?: (prev: T, next: T) => boolean): void {
const previous = this.pendingWrites.has(key) ? this.pendingWrites.get(key) as T : undefined;
if (previous !== undefined && equals && equals(previous, value)) {
return;
}
if (previous === value) {
return;
}
this.pendingWrites.set(key, value);
this.notify(key, value);
this.scheduleFlush();
}
remove(key: string): void {
this.pendingWrites.delete(key);
try {
localStorage.removeItem(key);
} catch {
// ignore quota / access errors
}
this.notify(key, undefined);
}
/**
* Subscribe to value changes for a key. Fires synchronously on each
* `write` (with the queued value) so signal-bridges stay reactive even
* if the flush is deferred. Returns an unsubscribe function.
*/
subscribe<T>(key: string, listener: (value: T | undefined) => void): () => void {
let subs = this.subscribers.get(key);
if (!subs) {
subs = new Set();
this.subscribers.set(key, subs);
}
subs.add(listener as (value: unknown) => void);
return () => {
const current = this.subscribers.get(key);
if (!current) {
return;
}
current.delete(listener as (value: unknown) => void);
if (current.size === 0) {
this.subscribers.delete(key);
}
};
}
/** Force any queued writes to disk immediately. */
flush(): void {
if (this.cancelScheduledFlush) {
this.cancelScheduledFlush();
this.cancelScheduledFlush = null;
}
this.flushScheduled = false;
this.runFlush();
}
private scheduleFlush(): void {
if (isSyncFlushEnvironment()) {
this.runFlush();
return;
}
if (this.flushScheduled) {
return;
}
this.flushScheduled = true;
this.cancelScheduledFlush = runWhenIdle(() => {
this.cancelScheduledFlush = null;
this.flushScheduled = false;
this.runFlush();
});
}
private runFlush(): void {
if (this.pendingWrites.size === 0) {
return;
}
const snapshot = Array.from(this.pendingWrites.entries());
this.pendingWrites.clear();
for (const [key, value] of snapshot) {
try {
const serialized = JSON.stringify(value);
localStorage.setItem(key, serialized);
} catch {
// quota exceeded / privacy mode - keep the value in cache so the
// caller still observes the update, just no longer persisted
}
}
}
private safeReadRaw(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
private notify(key: string, value: unknown): void {
const subs = this.subscribers.get(key);
if (!subs) {
return;
}
for (const listener of subs) {
try {
listener(value);
} catch {
// a subscriber failure should not block the others
}
}
}
}
/**
* Process-wide singleton. Consumers should prefer this over instantiating
* the class directly so writes coalesce across services.
*/
export const jsonStorage = new JsonStorageService();

View File

@@ -1,11 +1,13 @@
import {
Injectable,
inject,
signal,
computed,
type Signal
} from '@angular/core';
import { environment } from '../../../environments/environment';
import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants';
import { jsonStorage } from '../persistence/json-storage.service';
export interface IceServerEntry {
id: string;
@@ -26,6 +28,7 @@ export class IceServerSettingsService {
readonly entries: Signal<IceServerEntry[]>;
readonly rtcIceServers: Signal<RTCIceServer[]>;
private readonly storageService = jsonStorage;
private readonly _entries = signal<IceServerEntry[]>(this.load());
constructor() {
@@ -90,33 +93,23 @@ export class IceServerSettingsService {
}
private load(): IceServerEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY_ICE_SERVERS);
const parsed = this.storageService.read<unknown>(STORAGE_KEY_ICE_SERVERS, null);
if (!raw) {
return [...DEFAULT_ENTRIES];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) {
return [...DEFAULT_ENTRIES];
}
return parsed.filter(
(entry: unknown): entry is IceServerEntry =>
typeof entry === 'object'
&& entry !== null
&& typeof (entry as IceServerEntry).id === 'string'
&& ((entry as IceServerEntry).type === 'stun' || (entry as IceServerEntry).type === 'turn')
&& typeof (entry as IceServerEntry).urls === 'string'
);
} catch {
if (!Array.isArray(parsed) || parsed.length === 0) {
return [...DEFAULT_ENTRIES];
}
return parsed.filter(
(entry: unknown): entry is IceServerEntry =>
typeof entry === 'object'
&& entry !== null
&& typeof (entry as IceServerEntry).id === 'string'
&& ((entry as IceServerEntry).type === 'stun' || (entry as IceServerEntry).type === 'turn')
&& typeof (entry as IceServerEntry).urls === 'string'
);
}
private save(entries: IceServerEntry[]): void {
localStorage.setItem(STORAGE_KEY_ICE_SERVERS, JSON.stringify(entries));
this.storageService.write(STORAGE_KEY_ICE_SERVERS, entries);
}
}