feat: Add emoji and alot of other fixes

This commit is contained in:
2026-06-05 05:40:18 +02:00
parent ca069e2f61
commit 6865147e8f
72 changed files with 3885 additions and 413 deletions

View File

@@ -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.

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -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()); }
}

View File

@@ -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 } });