feat: Add emoji and alot of other fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Persistence Infrastructure
|
||||
|
||||
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.
|
||||
Offline-first storage layer that keeps messages, users, rooms, reactions, custom emoji, 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.
|
||||
|
||||
@@ -45,11 +45,12 @@ Both backends store the same entity types:
|
||||
| `users` | `oderId` | | User profiles |
|
||||
| `rooms` | `id` | | Server/room metadata |
|
||||
| `reactions` | `oderId-emoji-messageId` | | Emoji reactions, deduplicated per user |
|
||||
| `customEmojis` / `custom_emojis` | `id` | `updatedAt`, `creatorUserId` | Known custom emoji image assets synced over peer data channels; `savedByUser` controls picker/library membership |
|
||||
| `bans` | `oderId` | | Active bans per room |
|
||||
| `attachments` | `id` | | File/image metadata tied to messages |
|
||||
| `meta` | `key` | | Key-value pairs (e.g. `currentUserId`) |
|
||||
|
||||
The IndexedDB schema is at version 2.
|
||||
The IndexedDB schema is at version 3.
|
||||
|
||||
The persisted `rooms` store is a local cache of room metadata. Channel topology is still server-owned metadata: after room create, join, view, or channel-management changes, the renderer should hydrate the authoritative mixed text-and-voice channel list from server-directory responses so every member converges on the same room structure.
|
||||
|
||||
@@ -119,6 +120,8 @@ Every method on `DatabaseService` maps 1:1 to both backends:
|
||||
|
||||
**Attachments**: `saveAttachment`, `getAttachmentsForMessage`, `getAllAttachments`, `deleteAttachmentsForMessage`
|
||||
|
||||
**Custom emoji**: `saveCustomEmoji`, `getCustomEmojis`, `deleteCustomEmoji`
|
||||
|
||||
**Lifecycle**: `initialize`, `clearAllData`
|
||||
|
||||
The facade also exposes an `isReady` signal that flips to `true` after `initialize()` completes, so components can gate rendering until the DB is available.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta } from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import type { RoomMessageStats } from './database.service';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { RoomMessageStats } from './database.service';
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
|
||||
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||
const DATABASE_VERSION = 2;
|
||||
const DATABASE_VERSION = 3;
|
||||
/** Names of every object store used by the application. */
|
||||
const STORE_MESSAGES = 'messages';
|
||||
const STORE_USERS = 'users';
|
||||
@@ -25,6 +25,7 @@ const STORE_REACTIONS = 'reactions';
|
||||
const STORE_BANS = 'bans';
|
||||
const STORE_META = 'meta';
|
||||
const STORE_ATTACHMENTS = 'attachments';
|
||||
const STORE_CUSTOM_EMOJIS = 'customEmojis';
|
||||
/** All object store names, used when clearing the entire database. */
|
||||
const ALL_STORE_NAMES: string[] = [
|
||||
STORE_MESSAGES,
|
||||
@@ -33,6 +34,7 @@ const ALL_STORE_NAMES: string[] = [
|
||||
STORE_REACTIONS,
|
||||
STORE_BANS,
|
||||
STORE_ATTACHMENTS,
|
||||
STORE_CUSTOM_EMOJIS,
|
||||
STORE_META
|
||||
];
|
||||
|
||||
@@ -334,6 +336,18 @@ export class BrowserDatabaseService {
|
||||
return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
|
||||
}
|
||||
|
||||
async saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
|
||||
await this.put(STORE_CUSTOM_EMOJIS, emoji);
|
||||
}
|
||||
|
||||
async getCustomEmojis(): Promise<CustomEmoji[]> {
|
||||
return this.getAll<CustomEmoji>(STORE_CUSTOM_EMOJIS);
|
||||
}
|
||||
|
||||
async deleteCustomEmoji(emojiId: string): Promise<void> {
|
||||
await this.deleteRecord(STORE_CUSTOM_EMOJIS, emojiId);
|
||||
}
|
||||
|
||||
/** Delete every attachment record for a specific message. */
|
||||
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
|
||||
@@ -459,6 +473,11 @@ export class BrowserDatabaseService {
|
||||
const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(attachmentsStore, 'messageId', 'messageId');
|
||||
|
||||
const customEmojisStore = this.ensureStore(database, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(customEmojisStore, 'updatedAt', 'updatedAt');
|
||||
this.ensureIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
|
||||
}
|
||||
|
||||
private ensureStore(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Injector,
|
||||
runInInjectionContext
|
||||
} from '@angular/core';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
let browserDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let electronDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
browserDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
electronDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
});
|
||||
|
||||
function createService(): DatabaseService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
DatabaseService,
|
||||
{ provide: PlatformService, useValue: { isBrowser: true, isElectron: false } },
|
||||
{ provide: BrowserDatabaseService, useValue: browserDatabase },
|
||||
{ provide: ElectronDatabaseService, useValue: electronDatabase }
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(DatabaseService));
|
||||
}
|
||||
|
||||
it('initializes the selected backend before the first delegated read', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
|
||||
expect(service.isReady()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta } from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
@@ -36,6 +36,7 @@ export class DatabaseService {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly browserDb = inject(BrowserDatabaseService);
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
/** Reactive flag: `true` once {@link initialize} has completed. */
|
||||
isReady = signal(false);
|
||||
@@ -47,12 +48,39 @@ export class DatabaseService {
|
||||
|
||||
/** Initialise the platform-specific database. */
|
||||
async initialize(): Promise<void> {
|
||||
await this.backend.initialize();
|
||||
this.isReady.set(true);
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = this.backend;
|
||||
|
||||
this.initializationPromise = backend.initialize()
|
||||
.then(() => {
|
||||
this.isReady.set(true);
|
||||
})
|
||||
.finally(() => {
|
||||
this.initializationPromise = null;
|
||||
});
|
||||
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
private async ensureReady(): Promise<void> {
|
||||
if (this.isReady())
|
||||
return;
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
private async withReady<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await this.ensureReady();
|
||||
|
||||
return operation();
|
||||
}
|
||||
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message) { return this.backend.saveMessage(message); }
|
||||
saveMessage(message: Message) { return this.withReady(() => this.backend.saveMessage(message)); }
|
||||
|
||||
/** Retrieve the latest messages for a room or channel with optional pagination.
|
||||
*
|
||||
@@ -66,95 +94,104 @@ export class DatabaseService {
|
||||
offset = 0,
|
||||
channelId?: string,
|
||||
beforeTimestamp?: number
|
||||
) { return this.backend.getMessages(roomId, limit, offset, channelId, beforeTimestamp); }
|
||||
) { return this.withReady(() => this.backend.getMessages(roomId, limit, offset, channelId, beforeTimestamp)); }
|
||||
|
||||
/** Retrieve messages newer than a given timestamp for a room. */
|
||||
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.backend.getMessagesSince(roomId, sinceTimestamp); }
|
||||
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.withReady(() => this.backend.getMessagesSince(roomId, sinceTimestamp)); }
|
||||
|
||||
/** Retrieve aggregate message stats for sync handshakes without loading history. */
|
||||
getRoomMessageStats(roomId: string) { return this.backend.getRoomMessageStats(roomId); }
|
||||
getRoomMessageStats(roomId: string) { return this.withReady(() => this.backend.getRoomMessageStats(roomId)); }
|
||||
|
||||
/** Permanently delete a message by ID. */
|
||||
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }
|
||||
deleteMessage(messageId: string) { return this.withReady(() => this.backend.deleteMessage(messageId)); }
|
||||
|
||||
/** Apply partial updates to an existing message. */
|
||||
updateMessage(messageId: string, updates: Partial<Message>) { return this.backend.updateMessage(messageId, updates); }
|
||||
updateMessage(messageId: string, updates: Partial<Message>) { return this.withReady(() => this.backend.updateMessage(messageId, updates)); }
|
||||
|
||||
/** Retrieve a single message by ID. */
|
||||
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
|
||||
getMessageById(messageId: string) { return this.withReady(() => this.backend.getMessageById(messageId)); }
|
||||
|
||||
/** Remove every message belonging to a room. */
|
||||
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
|
||||
clearRoomMessages(roomId: string) { return this.withReady(() => this.backend.clearRoomMessages(roomId)); }
|
||||
|
||||
/** Persist a reaction. */
|
||||
saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); }
|
||||
saveReaction(reaction: Reaction) { return this.withReady(() => this.backend.saveReaction(reaction)); }
|
||||
|
||||
/** Remove a specific reaction (user + emoji + message). */
|
||||
removeReaction(messageId: string, userId: string, emoji: string) { return this.backend.removeReaction(messageId, userId, emoji); }
|
||||
removeReaction(messageId: string, userId: string, emoji: string) { return this.withReady(() => this.backend.removeReaction(messageId, userId, emoji)); }
|
||||
|
||||
/** Return all reactions for a given message. */
|
||||
getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); }
|
||||
getReactionsForMessage(messageId: string) { return this.withReady(() => this.backend.getReactionsForMessage(messageId)); }
|
||||
|
||||
/** Persist a user record. */
|
||||
saveUser(user: User) { return this.backend.saveUser(user); }
|
||||
saveUser(user: User) { return this.withReady(() => this.backend.saveUser(user)); }
|
||||
|
||||
/** Retrieve a user by ID. */
|
||||
getUser(userId: string) { return this.backend.getUser(userId); }
|
||||
getUser(userId: string) { return this.withReady(() => this.backend.getUser(userId)); }
|
||||
|
||||
/** Retrieve the current (logged-in) user. */
|
||||
getCurrentUser() { return this.backend.getCurrentUser(); }
|
||||
getCurrentUser() { return this.withReady(() => this.backend.getCurrentUser()); }
|
||||
|
||||
/** Retrieve the persisted current user ID without loading the full user. */
|
||||
getCurrentUserId() { return this.backend.getCurrentUserId(); }
|
||||
getCurrentUserId() { return this.withReady(() => this.backend.getCurrentUserId()); }
|
||||
|
||||
/** Store the current user ID. */
|
||||
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
|
||||
setCurrentUserId(userId: string) { return this.withReady(() => this.backend.setCurrentUserId(userId)); }
|
||||
|
||||
/** Retrieve users in a room. */
|
||||
getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); }
|
||||
getUsersByRoom(roomId: string) { return this.withReady(() => this.backend.getUsersByRoom(roomId)); }
|
||||
|
||||
/** Apply partial updates to an existing user. */
|
||||
updateUser(userId: string, updates: Partial<User>) { return this.backend.updateUser(userId, updates); }
|
||||
updateUser(userId: string, updates: Partial<User>) { return this.withReady(() => this.backend.updateUser(userId, updates)); }
|
||||
|
||||
/** Persist a room record. */
|
||||
saveRoom(room: Room) { return this.backend.saveRoom(room); }
|
||||
saveRoom(room: Room) { return this.withReady(() => this.backend.saveRoom(room)); }
|
||||
|
||||
/** Retrieve a room by ID. */
|
||||
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
|
||||
getRoom(roomId: string) { return this.withReady(() => this.backend.getRoom(roomId)); }
|
||||
|
||||
/** Return every persisted room. */
|
||||
getAllRooms() { return this.backend.getAllRooms(); }
|
||||
getAllRooms() { return this.withReady(() => this.backend.getAllRooms()); }
|
||||
|
||||
/** Delete a room and its associated messages. */
|
||||
deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); }
|
||||
deleteRoom(roomId: string) { return this.withReady(() => this.backend.deleteRoom(roomId)); }
|
||||
|
||||
/** Apply partial updates to an existing room. */
|
||||
updateRoom(roomId: string, updates: Partial<Room>) { return this.backend.updateRoom(roomId, updates); }
|
||||
updateRoom(roomId: string, updates: Partial<Room>) { return this.withReady(() => this.backend.updateRoom(roomId, updates)); }
|
||||
|
||||
/** Persist a ban entry. */
|
||||
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
|
||||
saveBan(ban: BanEntry) { return this.withReady(() => this.backend.saveBan(ban)); }
|
||||
|
||||
/** Remove a ban by oderId. */
|
||||
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
|
||||
removeBan(oderId: string) { return this.withReady(() => this.backend.removeBan(oderId)); }
|
||||
|
||||
/** Return active bans for a room. */
|
||||
getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); }
|
||||
getBansForRoom(roomId: string) { return this.withReady(() => this.backend.getBansForRoom(roomId)); }
|
||||
|
||||
/** Check whether a user is currently banned from a room. */
|
||||
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
|
||||
isUserBanned(userId: string, roomId: string) { return this.withReady(() => this.backend.isUserBanned(userId, roomId)); }
|
||||
|
||||
/** Persist attachment metadata. */
|
||||
saveAttachment(attachment: ChatAttachmentMeta) { return this.backend.saveAttachment(attachment); }
|
||||
saveAttachment(attachment: ChatAttachmentMeta) { return this.withReady(() => this.backend.saveAttachment(attachment)); }
|
||||
|
||||
/** Return all attachment records for a message. */
|
||||
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
||||
getAttachmentsForMessage(messageId: string) { return this.withReady(() => this.backend.getAttachmentsForMessage(messageId)); }
|
||||
|
||||
/** Return every persisted attachment record. */
|
||||
getAllAttachments() { return this.backend.getAllAttachments(); }
|
||||
getAllAttachments() { return this.withReady(() => this.backend.getAllAttachments()); }
|
||||
|
||||
/** Persist a custom emoji asset. */
|
||||
saveCustomEmoji(emoji: CustomEmoji) { return this.withReady(() => this.backend.saveCustomEmoji(emoji)); }
|
||||
|
||||
/** Return every known custom emoji asset. */
|
||||
getCustomEmojis() { return this.withReady(() => this.backend.getCustomEmojis()); }
|
||||
|
||||
/** Delete a custom emoji asset. */
|
||||
deleteCustomEmoji(emojiId: string) { return this.withReady(() => this.backend.deleteCustomEmoji(emojiId)); }
|
||||
|
||||
/** Delete all attachment records for a message. */
|
||||
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
|
||||
deleteAttachmentsForMessage(messageId: string) { return this.withReady(() => this.backend.deleteAttachmentsForMessage(messageId)); }
|
||||
|
||||
/** Wipe all persisted data. */
|
||||
clearAllData() { return this.backend.clearAllData(); }
|
||||
clearAllData() { return this.withReady(() => this.backend.clearAllData()); }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../../shared-kernel';
|
||||
import type { CustomEmoji } 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';
|
||||
@@ -203,6 +204,18 @@ export class ElectronDatabaseService {
|
||||
return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} });
|
||||
}
|
||||
|
||||
saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
|
||||
return this.api.command({ type: 'save-custom-emoji', payload: { emoji } });
|
||||
}
|
||||
|
||||
getCustomEmojis(): Promise<CustomEmoji[]> {
|
||||
return this.api.query<CustomEmoji[]>({ type: 'get-custom-emojis', payload: {} });
|
||||
}
|
||||
|
||||
deleteCustomEmoji(emojiId: string): Promise<void> {
|
||||
return this.api.command({ type: 'delete-custom-emoji', payload: { emojiId } });
|
||||
}
|
||||
|
||||
/** Delete all attachment records for a message. */
|
||||
deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
return this.api.command({ type: 'delete-attachments-for-message', payload: { messageId } });
|
||||
|
||||
Reference in New Issue
Block a user