/* eslint-disable, @typescript-eslint/no-non-null-assertion */ import { Injectable } from '@angular/core'; import { DELETED_MESSAGE_CONTENT, Message, User, Room, Reaction, 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. */ const STORE_MESSAGES = 'messages'; const STORE_USERS = 'users'; const STORE_ROOMS = 'rooms'; const STORE_REACTIONS = 'reactions'; const STORE_BANS = 'bans'; const STORE_META = 'meta'; const STORE_ATTACHMENTS = 'attachments'; /** All object store names, used when clearing the entire database. */ const ALL_STORE_NAMES: string[] = [ STORE_MESSAGES, STORE_USERS, STORE_ROOMS, STORE_REACTIONS, STORE_BANS, STORE_ATTACHMENTS, STORE_META ]; /** * IndexedDB-backed database service used when the app runs in a * plain browser (i.e. without Electron). * * Every public method mirrors the {@link DatabaseService} API so the * facade can delegate transparently. */ @Injectable({ providedIn: 'root' }) 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 { const databaseName = await this.resolveDatabaseName(); if (this.database && this.activeDatabaseName === databaseName) return; this.closeDatabase(); this.database = await this.openDatabase(databaseName); this.activeDatabaseName = databaseName; } /** Persist a single message. */ async saveMessage(message: Message): Promise { await this.put(STORE_MESSAGES, message); } /** * Retrieve the latest messages for a room, sorted oldest-first for display. * @param roomId - Target room. * @param limit - Maximum number of messages to return. * @param offset - Number of newer messages to skip (for pagination). * @param channelId - Optional channel scope; 'general' includes null/empty. * @param beforeTimestamp - Optional cursor; only messages strictly older * than this timestamp are returned. Used for * scroll-up history pagination. */ async getMessages( roomId: string, limit = 100, offset = 0, channelId?: string, beforeTimestamp?: number ): Promise { const allRoomMessages = await this.getAllFromIndex( STORE_MESSAGES, 'roomId', roomId ); const scopedMessages = channelId ? allRoomMessages.filter((message) => (message.channelId || 'general') === channelId) : allRoomMessages; const cursorFiltered = beforeTimestamp === undefined ? scopedMessages : scopedMessages.filter((message) => message.timestamp < beforeTimestamp); const sortedMessages = cursorFiltered.sort((first, second) => first.timestamp - second.timestamp); const endIndex = Math.max(sortedMessages.length - offset, 0); const startIndex = Math.max(endIndex - limit, 0); const messages = sortedMessages.slice(startIndex, endIndex); return this.hydrateMessages(messages); } async getMessagesSince(roomId: string, sinceTimestamp: number): Promise { const allRoomMessages = await this.getAllFromIndex( STORE_MESSAGES, 'roomId', roomId ); const messages = allRoomMessages .filter((message) => message.timestamp > sinceTimestamp) .sort((first, second) => first.timestamp - second.timestamp); return this.hydrateMessages(messages); } /** Delete a message by its ID. */ async deleteMessage(messageId: string): Promise { await this.deleteRecord(STORE_MESSAGES, messageId); } /** Apply partial updates to an existing message. */ async updateMessage(messageId: string, updates: Partial): Promise { const existing = await this.get(STORE_MESSAGES, messageId); if (existing) { await this.put(STORE_MESSAGES, { ...existing, ...updates }); } } /** Retrieve a single message by ID, or `null` if not found. */ async getMessageById(messageId: string): Promise { const message = await this.get(STORE_MESSAGES, messageId); if (!message) { return null; } return (await this.hydrateMessages([message]))[0] ?? null; } /** Remove every message belonging to a room. */ async clearRoomMessages(roomId: string): Promise { const messages = await this.getAllFromIndex( STORE_MESSAGES, 'roomId', roomId ); const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite'); for (const message of messages) { transaction.objectStore(STORE_MESSAGES).delete(message.id); } await this.awaitTransaction(transaction); } /** * Persist a reaction, ignoring duplicates (same user + same emoji on * the same message). */ async saveReaction(reaction: Reaction): Promise { const existing = await this.getAllFromIndex( STORE_REACTIONS, 'messageId', reaction.messageId ); const isDuplicate = existing.some( (entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji ); if (!isDuplicate) { await this.put(STORE_REACTIONS, reaction); } } /** Remove a specific reaction (identified by user + emoji + message). */ async removeReaction(messageId: string, userId: string, emoji: string): Promise { const reactions = await this.getAllFromIndex( STORE_REACTIONS, 'messageId', messageId ); const target = reactions.find( (entry) => entry.userId === userId && entry.emoji === emoji ); if (target) { await this.deleteRecord(STORE_REACTIONS, target.id); } } /** Return all reactions for a given message. */ async getReactionsForMessage(messageId: string): Promise { return this.getAllFromIndex(STORE_REACTIONS, 'messageId', messageId); } /** Persist a user record. */ async saveUser(user: User): Promise { await this.put(STORE_USERS, user); } /** Retrieve a user by ID, or `null` if not found. */ async getUser(userId: string): Promise { return (await this.get(STORE_USERS, userId)) ?? null; } /** Retrieve the last-authenticated ("current") user, or `null`. */ async getCurrentUser(): Promise { const meta = await this.get<{ id: string; value: string }>( STORE_META, 'currentUserId' ); if (!meta) return null; return this.getUser(meta.value); } /** Retrieve the persisted current user ID without loading the full user. */ async getCurrentUserId(): Promise { 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 { await this.put(STORE_META, { id: 'currentUserId', value: userId }); } /** * Retrieve all known users. * @param _roomId - Accepted for API parity but currently unused. */ async getUsersByRoom(_roomId: string): Promise { return this.getAll(STORE_USERS); } /** Apply partial updates to an existing user. */ async updateUser(userId: string, updates: Partial): Promise { const existing = await this.get(STORE_USERS, userId); if (existing) { await this.put(STORE_USERS, { ...existing, ...updates }); } } /** Persist a room record. */ async saveRoom(room: Room): Promise { await this.put(STORE_ROOMS, room); } /** Retrieve a room by ID, or `null` if not found. */ async getRoom(roomId: string): Promise { return (await this.get(STORE_ROOMS, roomId)) ?? null; } /** Return every persisted room. */ async getAllRooms(): Promise { return this.getAll(STORE_ROOMS); } /** Delete a room and all of its messages. */ async deleteRoom(roomId: string): Promise { await this.deleteRecord(STORE_ROOMS, roomId); await this.clearRoomMessages(roomId); } /** Apply partial updates to an existing room. */ async updateRoom(roomId: string, updates: Partial): Promise { const existing = await this.get(STORE_ROOMS, roomId); if (existing) { await this.put(STORE_ROOMS, { ...existing, ...updates }); } } /** Persist a ban entry. */ async saveBan(ban: BanEntry): Promise { await this.put(STORE_BANS, ban); } /** Remove a ban by the banned user's `oderId`. */ async removeBan(oderId: string): Promise { const allBans = await this.getAll(STORE_BANS); const match = allBans.find((ban) => ban.oderId === oderId); if (match) { await this.deleteRecord(STORE_BANS, match.oderId); } } /** * Return active (non-expired) bans for a room. * * @param roomId - Room to query. */ async getBansForRoom(roomId: string): Promise { const allBans = await this.getAllFromIndex( STORE_BANS, 'roomId', roomId ); const now = Date.now(); return allBans.filter( (ban) => !ban.expiresAt || ban.expiresAt > now ); } /** Check whether a specific user is currently banned from a room. */ async isUserBanned(userId: string, roomId: string): Promise { const activeBans = await this.getBansForRoom(roomId); return activeBans.some((ban) => ban.oderId === userId); } /** Persist attachment metadata associated with a chat message. */ async saveAttachment(attachment: ChatAttachmentMeta): Promise { await this.put(STORE_ATTACHMENTS, attachment); } /** Return all attachment records associated with a message. */ async getAttachmentsForMessage(messageId: string): Promise { return this.getAllFromIndex(STORE_ATTACHMENTS, 'messageId', messageId); } /** Return every persisted attachment record. */ async getAllAttachments(): Promise { return this.getAll(STORE_ATTACHMENTS); } /** Delete every attachment record for a specific message. */ async deleteAttachmentsForMessage(messageId: string): Promise { const attachments = await this.getAllFromIndex( STORE_ATTACHMENTS, 'messageId', messageId ); const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite'); for (const attachment of attachments) { transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id); } await this.awaitTransaction(transaction); } /** Wipe all persisted data in every object store. */ async clearAllData(): Promise { const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite'); for (const storeName of ALL_STORE_NAMES) { transaction.objectStore(storeName).clear(); } await this.awaitTransaction(transaction); } private async resolveDatabaseName(): Promise { 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 { 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 { 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((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 { return new Promise((resolve, reject) => { const request = indexedDB.open(databaseName, DATABASE_VERSION); request.onerror = () => reject(request.error); request.onupgradeneeded = () => this.setupSchema(request.result); request.onsuccess = () => resolve(request.result); }); } 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' }); this.ensureIndex(messagesStore, 'roomId', 'roomId'); this.ensureIndex(messagesStore, 'timestamp', 'timestamp'); this.ensureStore(database, STORE_USERS, { keyPath: 'id' }); const roomsStore = this.ensureStore(database, STORE_ROOMS, { keyPath: 'id' }); this.ensureIndex(roomsStore, 'timestamp', 'timestamp'); const reactionsStore = this.ensureStore(database, STORE_REACTIONS, { keyPath: 'id' }); this.ensureIndex(reactionsStore, 'messageId', 'messageId'); this.ensureIndex(reactionsStore, 'userId', 'userId'); const bansStore = this.ensureStore(database, STORE_BANS, { keyPath: 'oderId' }); this.ensureIndex(bansStore, 'roomId', 'roomId'); this.ensureIndex(bansStore, 'expiresAt', 'expiresAt'); this.ensureStore(database, STORE_META, { keyPath: 'id' }); const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' }); this.ensureIndex(attachmentsStore, 'messageId', 'messageId'); } private ensureStore( database: IDBDatabase, name: string, options?: IDBObjectStoreParameters ): IDBObjectStore { if (database.objectStoreNames.contains(name)) { return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name); } return database.createObjectStore(name, options); } private ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void { if (!store.indexNames.contains(name)) { store.createIndex(name, keyPath, { unique: false }); } } private createTransaction( storeNames: string | string[], mode: IDBTransactionMode ): IDBTransaction { if (!this.database) { throw new Error('Database has not been initialized'); } return this.database.transaction(storeNames, mode); } private awaitTransaction(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); } private async put(storeName: string, value: unknown): Promise { const transaction = this.createTransaction(storeName, 'readwrite'); transaction.objectStore(storeName).put(value); await this.awaitTransaction(transaction); } private async get(storeName: string, key: IDBValidKey): Promise { const transaction = this.createTransaction(storeName, 'readonly'); const request = transaction.objectStore(storeName).get(key); return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result as T | undefined); request.onerror = () => reject(request.error); }); } private async getAll(storeName: string): Promise { const transaction = this.createTransaction(storeName, 'readonly'); const request = transaction.objectStore(storeName).getAll(); return new Promise((resolve, reject) => { request.onsuccess = () => resolve((request.result as T[]) ?? []); request.onerror = () => reject(request.error); }); } private async getAllFromIndex( storeName: string, indexName: string, query: IDBValidKey | IDBKeyRange ): Promise { const transaction = this.createTransaction(storeName, 'readonly'); const request = transaction.objectStore(storeName) .index(indexName) .getAll(query); return new Promise((resolve, reject) => { request.onsuccess = () => resolve((request.result as T[]) ?? []); request.onerror = () => reject(request.error); }); } private async deleteRecord(storeName: string, key: IDBValidKey): Promise { const transaction = this.createTransaction(storeName, 'readwrite'); transaction.objectStore(storeName).delete(key); await this.awaitTransaction(transaction); } private async hydrateMessages(messages: Message[]): Promise { if (messages.length === 0) { return []; } const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id)); return messages.map((message) => this.normaliseMessage({ ...message, reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? [] })); } private async loadReactionsForMessages(messageIds: readonly string[]): Promise> { const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0)); const reactionsByMessageId = new Map(); if (messageIdSet.size === 0) { return reactionsByMessageId; } const allReactions = await this.getAll(STORE_REACTIONS); for (const reaction of allReactions) { if (!messageIdSet.has(reaction.messageId)) { continue; } const reactions = reactionsByMessageId.get(reaction.messageId) ?? []; reactions.push(reaction); reactionsByMessageId.set(reaction.messageId, reactions); } for (const reactions of reactionsByMessageId.values()) { reactions.sort((first, second) => first.timestamp - second.timestamp); } return reactionsByMessageId; } private normaliseMessage(message: Message): Message { if (message.content === DELETED_MESSAGE_CONTENT) { return { ...message, reactions: [] }; } return message; } }