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 } });
|
||||
|
||||
@@ -121,6 +121,8 @@ Each signaling URL gets its own `SignalingManager` (one WebSocket each). `Signal
|
||||
|
||||
Room affinity is authoritative at this layer as well. The renderer repairs each room's saved `sourceId` / `sourceUrl` from server-directory responses and routes `join_server`, `view_server`, and room-scoped signaling traffic to that room's signaling URL first. If that route fails, alternate endpoints can be tried temporarily, but server-scoped raw messages are no longer broadcast to every connected signaling manager when the route is unknown.
|
||||
|
||||
In UI/debug conversations, a **chat-server** means one of the saved rooms navigated from the server rail. Each chat-server has its own assigned signal server via `sourceId` / `sourceUrl`, and room-scoped feature/config checks must prefer that signal server before considering any global active endpoint. For example, KLIPY GIF picker visibility is resolved against the currently viewed chat-server's signal server so an unrelated offline chat-server does not hide the button everywhere.
|
||||
|
||||
Cold-start routing now waits for the initial server-directory health probes so same-backend aliases can collapse to one canonical signaling endpoint before any saved rooms reconnect. When a room is reconnected on a chosen socket, its background rooms are re-joined on that same socket as well so stale per-signal memberships do not keep orphan managers alive, and reconnect replay only sends `view_server` for rooms that manager still has joined.
|
||||
|
||||
This is still a non-federated model. Different signaling servers do not share peer registries or relay WebRTC offers for each other, so users in the same room must converge on the same signaling endpoint to discover one another reliably.
|
||||
@@ -152,6 +154,8 @@ sequenceDiagram
|
||||
|
||||
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything.
|
||||
|
||||
The browser also sends a lightweight `keepalive` message on the signaling socket during long-lived sessions. The server treats both WebSocket pong frames and any inbound client message as liveness, so users who are still active in voice or chat are not removed from server presence just because control-frame pong delivery stalls behind a proxy or runtime quirk.
|
||||
|
||||
### Server-side connection hygiene
|
||||
|
||||
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). The server's `handleIdentify` now closes any existing connection that shares the same `oderId` but a different `connectionId`. This guarantees `findUserByOderId` always routes offers and presence events to the freshest socket, eliminating a class of bugs where signaling messages landed on a dead tab's socket and were silently lost.
|
||||
|
||||
@@ -29,6 +29,8 @@ export const PEER_DISCONNECT_GRACE_MS = 10_000;
|
||||
|
||||
/** Interval (ms) for broadcasting state heartbeats */
|
||||
export const STATE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
/** Interval (ms) for application-level signaling keepalive messages */
|
||||
export const SIGNALING_KEEPALIVE_INTERVAL_MS = 25_000;
|
||||
/** Interval (ms) for broadcasting voice presence */
|
||||
export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
|
||||
@@ -85,6 +87,7 @@ export const SIGNALING_TYPE_SERVER_USERS = 'server_users';
|
||||
export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
|
||||
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
|
||||
export const SIGNALING_TYPE_ACCESS_DENIED = 'access_denied';
|
||||
export const SIGNALING_TYPE_KEEPALIVE = 'keepalive';
|
||||
|
||||
export const P2P_TYPE_STATE_REQUEST = 'state-request';
|
||||
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
SIGNALING_RECONNECT_MAX_DELAY_MS,
|
||||
SIGNALING_CONNECT_TIMEOUT_MS,
|
||||
STATE_HEARTBEAT_INTERVAL_MS,
|
||||
SIGNALING_KEEPALIVE_INTERVAL_MS,
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_KEEPALIVE,
|
||||
SIGNALING_TYPE_VIEW_SERVER
|
||||
} from '../realtime.constants';
|
||||
|
||||
@@ -39,6 +41,7 @@ export class SignalingManager {
|
||||
private signalingReconnectAttempts = 0;
|
||||
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastKeepaliveSentAt = 0;
|
||||
|
||||
/** Fires every heartbeat tick - the main service hooks this to broadcast state. */
|
||||
readonly heartbeatTick$ = new Subject<void>();
|
||||
@@ -391,7 +394,11 @@ export class SignalingManager {
|
||||
/** Start the heartbeat interval that drives periodic state broadcasts. */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
|
||||
this.lastKeepaliveSentAt = Date.now();
|
||||
this.stateHeartbeatTimer = setInterval(() => {
|
||||
this.heartbeatTick$.next();
|
||||
this.sendKeepaliveIfDue();
|
||||
}, STATE_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the heartbeat interval. */
|
||||
@@ -400,6 +407,28 @@ export class SignalingManager {
|
||||
clearInterval(this.stateHeartbeatTimer);
|
||||
this.stateHeartbeatTimer = null;
|
||||
}
|
||||
|
||||
this.lastKeepaliveSentAt = 0;
|
||||
}
|
||||
|
||||
private sendKeepaliveIfDue(): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastKeepaliveSentAt < SIGNALING_KEEPALIVE_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastKeepaliveSentAt = now;
|
||||
|
||||
try {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_KEEPALIVE });
|
||||
} catch (error) {
|
||||
this.logger.warn('[signaling] Failed to send signaling keepalive', {
|
||||
error,
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
|
||||
Reference in New Issue
Block a user