wip: optimizations
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user