Move toju-app into own its folder
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
/* 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';
|
||||
|
||||
/** IndexedDB database name for the MetoYou application. */
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
/** 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;
|
||||
|
||||
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
|
||||
async initialize(): Promise<void> {
|
||||
if (this.database)
|
||||
return;
|
||||
|
||||
this.database = await this.openDatabase();
|
||||
}
|
||||
|
||||
/** Persist a single message. */
|
||||
async saveMessage(message: Message): Promise<void> {
|
||||
await this.put(STORE_MESSAGES, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve messages for a room, sorted oldest-first.
|
||||
* @param roomId - Target room.
|
||||
* @param limit - Maximum number of messages to return.
|
||||
* @param offset - Number of messages to skip (for pagination).
|
||||
*/
|
||||
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||
const allRoomMessages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId
|
||||
);
|
||||
|
||||
return allRoomMessages
|
||||
.sort((first, second) => first.timestamp - second.timestamp)
|
||||
.slice(offset, offset + limit)
|
||||
.map((message) => this.normaliseMessage(message));
|
||||
}
|
||||
|
||||
/** Delete a message by its ID. */
|
||||
async deleteMessage(messageId: string): Promise<void> {
|
||||
await this.deleteRecord(STORE_MESSAGES, messageId);
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing message. */
|
||||
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||
const existing = await this.get<Message>(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<Message | null> {
|
||||
const message = await this.get<Message>(STORE_MESSAGES, messageId);
|
||||
|
||||
return message ? this.normaliseMessage(message) : null;
|
||||
}
|
||||
|
||||
/** Remove every message belonging to a room. */
|
||||
async clearRoomMessages(roomId: string): Promise<void> {
|
||||
const messages = await this.getAllFromIndex<Message>(
|
||||
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<void> {
|
||||
const existing = await this.getAllFromIndex<Reaction>(
|
||||
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<void> {
|
||||
const reactions = await this.getAllFromIndex<Reaction>(
|
||||
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<Reaction[]> {
|
||||
return this.getAllFromIndex<Reaction>(STORE_REACTIONS, 'messageId', messageId);
|
||||
}
|
||||
|
||||
/** Persist a user record. */
|
||||
async saveUser(user: User): Promise<void> {
|
||||
await this.put(STORE_USERS, user);
|
||||
}
|
||||
|
||||
/** Retrieve a user by ID, or `null` if not found. */
|
||||
async getUser(userId: string): Promise<User | null> {
|
||||
return (await this.get<User>(STORE_USERS, userId)) ?? null;
|
||||
}
|
||||
|
||||
/** Retrieve the last-authenticated ("current") user, or `null`. */
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
const meta = await this.get<{ id: string; value: string }>(
|
||||
STORE_META, 'currentUserId'
|
||||
);
|
||||
|
||||
if (!meta)
|
||||
return null;
|
||||
|
||||
return this.getUser(meta.value);
|
||||
}
|
||||
|
||||
/** Store which user ID is considered "current" (logged-in). */
|
||||
async setCurrentUserId(userId: string): Promise<void> {
|
||||
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<User[]> {
|
||||
return this.getAll<User>(STORE_USERS);
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing user. */
|
||||
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||
const existing = await this.get<User>(STORE_USERS, userId);
|
||||
|
||||
if (existing) {
|
||||
await this.put(STORE_USERS, { ...existing,
|
||||
...updates });
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a room record. */
|
||||
async saveRoom(room: Room): Promise<void> {
|
||||
await this.put(STORE_ROOMS, room);
|
||||
}
|
||||
|
||||
/** Retrieve a room by ID, or `null` if not found. */
|
||||
async getRoom(roomId: string): Promise<Room | null> {
|
||||
return (await this.get<Room>(STORE_ROOMS, roomId)) ?? null;
|
||||
}
|
||||
|
||||
/** Return every persisted room. */
|
||||
async getAllRooms(): Promise<Room[]> {
|
||||
return this.getAll<Room>(STORE_ROOMS);
|
||||
}
|
||||
|
||||
/** Delete a room and all of its messages. */
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
await this.deleteRecord(STORE_ROOMS, roomId);
|
||||
await this.clearRoomMessages(roomId);
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing room. */
|
||||
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||
const existing = await this.get<Room>(STORE_ROOMS, roomId);
|
||||
|
||||
if (existing) {
|
||||
await this.put(STORE_ROOMS, { ...existing,
|
||||
...updates });
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a ban entry. */
|
||||
async saveBan(ban: BanEntry): Promise<void> {
|
||||
await this.put(STORE_BANS, ban);
|
||||
}
|
||||
|
||||
/** Remove a ban by the banned user's `oderId`. */
|
||||
async removeBan(oderId: string): Promise<void> {
|
||||
const allBans = await this.getAll<BanEntry>(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<BanEntry[]> {
|
||||
const allBans = await this.getAllFromIndex<BanEntry>(
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.put(STORE_ATTACHMENTS, attachment);
|
||||
}
|
||||
|
||||
/** Return all attachment records associated with a message. */
|
||||
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
|
||||
return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
|
||||
}
|
||||
|
||||
/** Return every persisted attachment record. */
|
||||
async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
|
||||
return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
|
||||
}
|
||||
|
||||
/** Delete every attachment record for a specific message. */
|
||||
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
|
||||
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<void> {
|
||||
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
|
||||
|
||||
for (const storeName of ALL_STORE_NAMES) {
|
||||
transaction.objectStore(storeName).clear();
|
||||
}
|
||||
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
private openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onupgradeneeded = () => this.setupSchema(request.result);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
|
||||
transaction.objectStore(storeName).put(value);
|
||||
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
private async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
|
||||
const transaction = this.createTransaction(storeName, 'readonly');
|
||||
const request = transaction.objectStore(storeName).get(key);
|
||||
|
||||
return new Promise<T | undefined>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result as T | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async getAll<T>(storeName: string): Promise<T[]> {
|
||||
const transaction = this.createTransaction(storeName, 'readonly');
|
||||
const request = transaction.objectStore(storeName).getAll();
|
||||
|
||||
return new Promise<T[]>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve((request.result as T[]) ?? []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async getAllFromIndex<T>(
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
query: IDBValidKey | IDBKeyRange
|
||||
): Promise<T[]> {
|
||||
const transaction = this.createTransaction(storeName, 'readonly');
|
||||
const request = transaction.objectStore(storeName)
|
||||
.index(indexName)
|
||||
.getAll(query);
|
||||
|
||||
return new Promise<T[]>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve((request.result as T[]) ?? []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
|
||||
transaction.objectStore(storeName).delete(key);
|
||||
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
private normaliseMessage(message: Message): Message {
|
||||
if (message.content === DELETED_MESSAGE_CONTENT) {
|
||||
return { ...message,
|
||||
reactions: [] };
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user