fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
Offline-first storage layer that keeps messages, users, rooms, reactions, bans, and attachments on the client. The rest of the app only ever talks to `DatabaseService`, which picks the right backend for the current platform at runtime.
|
||||
|
||||
Persisted data is treated as belonging to the authenticated user that created it. In the browser runtime, IndexedDB is user-scoped: the renderer opens a per-user database for the active account and switches scopes during authentication so one account never boots into another account's stored rooms, messages, or settings.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
@@ -13,7 +15,7 @@ persistence/
|
||||
└── electron-database.service.ts IPC/SQLite backend (desktop)
|
||||
```
|
||||
|
||||
`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite.
|
||||
`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite. Those values use user-scoped storage keys so each account restores its own resume state instead of overwriting another user's snapshot.
|
||||
|
||||
## Platform routing
|
||||
|
||||
@@ -55,7 +57,7 @@ The persisted `rooms` store is a local cache of room metadata. Channel topology
|
||||
|
||||
### Browser (IndexedDB)
|
||||
|
||||
All operations run inside IndexedDB transactions in the renderer thread. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string).
|
||||
All operations run inside IndexedDB transactions in the renderer thread. The browser backend resolves the active database name from the logged-in user, reusing a legacy shared database only when it already belongs to that same account. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STORAGE_KEY_GENERAL_SETTINGS, STORAGE_KEY_LAST_VIEWED_CHAT } from '../../core/constants';
|
||||
import { getUserScopedStorageKey } from '../../core/storage/current-user-storage';
|
||||
|
||||
export interface GeneralSettings {
|
||||
reopenLastViewedChat: boolean;
|
||||
@@ -16,7 +17,8 @@ export interface LastViewedChatSnapshot {
|
||||
|
||||
export function loadGeneralSettingsFromStorage(): GeneralSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
|
||||
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
|
||||
?? localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
|
||||
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_GENERAL_SETTINGS };
|
||||
@@ -35,7 +37,7 @@ export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): G
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_GENERAL_SETTINGS, JSON.stringify(nextSettings));
|
||||
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS), JSON.stringify(nextSettings));
|
||||
} catch {}
|
||||
|
||||
return nextSettings;
|
||||
@@ -43,7 +45,8 @@ export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): G
|
||||
|
||||
export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
|
||||
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
|
||||
?? localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
@@ -73,12 +76,13 @@ export function saveLastViewedChatToStorage(snapshot: LastViewedChatSnapshot): v
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_LAST_VIEWED_CHAT, JSON.stringify(normalised));
|
||||
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, normalised.userId), JSON.stringify(normalised));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearLastViewedChatFromStorage(): void {
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
BanEntry
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta } from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
|
||||
/** IndexedDB database name for the MetoYou application. */
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
|
||||
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||
const DATABASE_VERSION = 2;
|
||||
/** Names of every object store used by the application. */
|
||||
@@ -44,13 +46,18 @@ const ALL_STORE_NAMES: string[] = [
|
||||
export class BrowserDatabaseService {
|
||||
/** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */
|
||||
private database: IDBDatabase | null = null;
|
||||
private activeDatabaseName: string | null = null;
|
||||
|
||||
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
|
||||
async initialize(): Promise<void> {
|
||||
if (this.database)
|
||||
const databaseName = await this.resolveDatabaseName();
|
||||
|
||||
if (this.database && this.activeDatabaseName === databaseName)
|
||||
return;
|
||||
|
||||
this.database = await this.openDatabase();
|
||||
this.closeDatabase();
|
||||
this.database = await this.openDatabase(databaseName);
|
||||
this.activeDatabaseName = databaseName;
|
||||
}
|
||||
|
||||
/** Persist a single message. */
|
||||
@@ -180,6 +187,15 @@ export class BrowserDatabaseService {
|
||||
return this.getUser(meta.value);
|
||||
}
|
||||
|
||||
/** Retrieve the persisted current user ID without loading the full user. */
|
||||
async getCurrentUserId(): Promise<string | null> {
|
||||
const meta = await this.get<{ id: string; value: string }>(
|
||||
STORE_META, 'currentUserId'
|
||||
);
|
||||
|
||||
return meta?.value?.trim() || null;
|
||||
}
|
||||
|
||||
/** Store which user ID is considered "current" (logged-in). */
|
||||
async setCurrentUserId(userId: string): Promise<void> {
|
||||
await this.put(STORE_META, { id: 'currentUserId',
|
||||
@@ -313,9 +329,66 @@ export class BrowserDatabaseService {
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
private openDatabase(): Promise<IDBDatabase> {
|
||||
private async resolveDatabaseName(): Promise<string> {
|
||||
const currentUserId = getStoredCurrentUserId();
|
||||
const scopedDatabaseName = this.createScopedDatabaseName(currentUserId);
|
||||
|
||||
if (!currentUserId) {
|
||||
return scopedDatabaseName;
|
||||
}
|
||||
|
||||
if (await this.databaseExists(scopedDatabaseName)) {
|
||||
return scopedDatabaseName;
|
||||
}
|
||||
|
||||
const legacyCurrentUserId = await this.readCurrentUserIdFromDatabase(DATABASE_NAME);
|
||||
|
||||
return legacyCurrentUserId === currentUserId
|
||||
? DATABASE_NAME
|
||||
: scopedDatabaseName;
|
||||
}
|
||||
|
||||
private createScopedDatabaseName(userId: string | null): string {
|
||||
return `${DATABASE_NAME}::${encodeURIComponent(userId || ANONYMOUS_DATABASE_SCOPE)}`;
|
||||
}
|
||||
|
||||
private async databaseExists(name: string): Promise<boolean> {
|
||||
const hasDatabasesApi = typeof indexedDB.databases === 'function';
|
||||
|
||||
if (!hasDatabasesApi) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const databases = await indexedDB.databases();
|
||||
|
||||
return databases.some((database) => database.name === name);
|
||||
}
|
||||
|
||||
private async readCurrentUserIdFromDatabase(databaseName: string): Promise<string | null> {
|
||||
if (!await this.databaseExists(databaseName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const database = await this.openDatabase(databaseName);
|
||||
|
||||
try {
|
||||
const transaction = database.transaction(STORE_META, 'readonly');
|
||||
const request = transaction.objectStore(STORE_META).get('currentUserId');
|
||||
|
||||
return await new Promise<string | null>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve((request.result as { value?: string } | undefined)?.value?.trim() || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
private openDatabase(databaseName: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
const request = indexedDB.open(databaseName, DATABASE_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onupgradeneeded = () => this.setupSchema(request.result);
|
||||
@@ -323,6 +396,12 @@ export class BrowserDatabaseService {
|
||||
});
|
||||
}
|
||||
|
||||
private closeDatabase(): void {
|
||||
this.database?.close();
|
||||
this.database = null;
|
||||
this.activeDatabaseName = null;
|
||||
}
|
||||
|
||||
private setupSchema(database: IDBDatabase): void {
|
||||
const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ export class DatabaseService {
|
||||
/** Retrieve the current (logged-in) user. */
|
||||
getCurrentUser() { return this.backend.getCurrentUser(); }
|
||||
|
||||
/** Retrieve the persisted current user ID without loading the full user. */
|
||||
getCurrentUserId() { return this.backend.getCurrentUserId(); }
|
||||
|
||||
/** Store the current user ID. */
|
||||
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
|
||||
|
||||
|
||||
@@ -101,6 +101,11 @@ export class ElectronDatabaseService {
|
||||
return this.api.query<User | null>({ type: 'get-current-user', payload: {} });
|
||||
}
|
||||
|
||||
/** Retrieve the persisted current user ID without loading the full user. */
|
||||
getCurrentUserId(): Promise<string | null> {
|
||||
return this.api.query<string | null>({ type: 'get-current-user-id', payload: {} });
|
||||
}
|
||||
|
||||
/** Store which user ID is considered "current" (logged-in). */
|
||||
setCurrentUserId(userId: string): Promise<void> {
|
||||
return this.api.command({ type: 'set-current-user-id', payload: { userId } });
|
||||
|
||||
Reference in New Issue
Block a user