Refacor electron app and add migrations
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */
|
||||
/* eslint-disable @angular-eslint/component-class-suffix */
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
@@ -51,16 +51,16 @@ import {
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit {
|
||||
store = inject(Store);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
private databaseService = inject(DatabaseService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
/** Intercept all <a> clicks and open them externally. */
|
||||
@HostListener('document:click', ['$event'])
|
||||
onGlobalLinkClick(evt: MouseEvent): void {
|
||||
|
||||
@@ -7,35 +7,40 @@ import {
|
||||
BanEntry
|
||||
} from '../models';
|
||||
|
||||
/** CQRS API exposed by the Electron preload script via `contextBridge`. */
|
||||
interface ElectronAPI {
|
||||
command<T = unknown>(command: { type: string; payload: unknown }): Promise<T>;
|
||||
query<T = unknown>(query: { type: string; payload: unknown }): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database service for the Electron (desktop) runtime.
|
||||
*
|
||||
* All SQLite queries run in the Electron **main process**
|
||||
* (`electron/database.js`). This service is a thin IPC client that
|
||||
* delegates every operation to `window.electronAPI.db.*`.
|
||||
* The SQLite database is managed by TypeORM in the Electron **main process**
|
||||
* (`electron/main.ts`). This service is a thin CQRS IPC client that dispatches
|
||||
* structured command/query objects through the unified `cqrs:command` and
|
||||
* `cqrs:query` channels exposed by the preload script.
|
||||
*
|
||||
* No initialisation IPC call is needed – the database is initialised and
|
||||
* migrations are run in main.ts before the renderer window is created.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronDatabaseService {
|
||||
/** Whether {@link initialize} has already been called successfully. */
|
||||
private isInitialised = false;
|
||||
|
||||
/** Shorthand accessor for the preload-exposed database API. */
|
||||
private get api() {
|
||||
return (window as any).electronAPI.db;
|
||||
/** Shorthand accessor for the preload-exposed CQRS API. */
|
||||
private get api(): ElectronAPI {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (window as any).electronAPI as ElectronAPI;
|
||||
}
|
||||
|
||||
/** Initialise the SQLite database via the main-process IPC bridge. */
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialised)
|
||||
return;
|
||||
|
||||
await this.api.initialize();
|
||||
this.isInitialised = true;
|
||||
}
|
||||
/**
|
||||
* No-op: the database is initialised in the main process before the
|
||||
* renderer window opens and requires no explicit bootstrap call here.
|
||||
*/
|
||||
async initialize(): Promise<void> { /* no-op */ }
|
||||
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message): Promise<void> {
|
||||
return this.api.saveMessage(message);
|
||||
return this.api.command({ type: 'save-message', payload: { message } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,141 +51,146 @@ export class ElectronDatabaseService {
|
||||
* @param offset - Number of messages to skip (for pagination).
|
||||
*/
|
||||
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||
return this.api.getMessages(roomId, limit, offset);
|
||||
return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } });
|
||||
}
|
||||
|
||||
/** Permanently delete a message by ID. */
|
||||
deleteMessage(messageId: string): Promise<void> {
|
||||
return this.api.deleteMessage(messageId);
|
||||
return this.api.command({ type: 'delete-message', payload: { messageId } });
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing message. */
|
||||
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||
return this.api.updateMessage(messageId, updates);
|
||||
return this.api.command({ type: 'update-message', payload: { messageId, updates } });
|
||||
}
|
||||
|
||||
/** Retrieve a single message by ID, or `null` if not found. */
|
||||
getMessageById(messageId: string): Promise<Message | null> {
|
||||
return this.api.getMessageById(messageId);
|
||||
return this.api.query<Message | null>({ type: 'get-message-by-id', payload: { messageId } });
|
||||
}
|
||||
|
||||
/** Remove every message belonging to a room. */
|
||||
clearRoomMessages(roomId: string): Promise<void> {
|
||||
return this.api.clearRoomMessages(roomId);
|
||||
return this.api.command({ type: 'clear-room-messages', payload: { roomId } });
|
||||
}
|
||||
|
||||
/** Persist a reaction (deduplication is handled server-side). */
|
||||
/** Persist a reaction (deduplication is handled main-process side). */
|
||||
saveReaction(reaction: Reaction): Promise<void> {
|
||||
return this.api.saveReaction(reaction);
|
||||
return this.api.command({ type: 'save-reaction', payload: { reaction } });
|
||||
}
|
||||
|
||||
/** Remove a specific reaction (user + emoji + message). */
|
||||
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
||||
return this.api.removeReaction(messageId, userId, emoji);
|
||||
return this.api.command({ type: 'remove-reaction', payload: { messageId, userId, emoji } });
|
||||
}
|
||||
|
||||
/** Return all reactions for a given message. */
|
||||
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
||||
return this.api.getReactionsForMessage(messageId);
|
||||
return this.api.query<Reaction[]>({ type: 'get-reactions-for-message', payload: { messageId } });
|
||||
}
|
||||
|
||||
/** Persist a user record. */
|
||||
saveUser(user: User): Promise<void> {
|
||||
return this.api.saveUser(user);
|
||||
return this.api.command({ type: 'save-user', payload: { user } });
|
||||
}
|
||||
|
||||
/** Retrieve a user by ID, or `null` if not found. */
|
||||
getUser(userId: string): Promise<User | null> {
|
||||
return this.api.getUser(userId);
|
||||
return this.api.query<User | null>({ type: 'get-user', payload: { userId } });
|
||||
}
|
||||
|
||||
/** Retrieve the last-authenticated ("current") user, or `null`. */
|
||||
getCurrentUser(): Promise<User | null> {
|
||||
return this.api.getCurrentUser();
|
||||
return this.api.query<User | null>({ type: 'get-current-user', payload: {} });
|
||||
}
|
||||
|
||||
/** Store which user ID is considered "current" (logged-in). */
|
||||
setCurrentUserId(userId: string): Promise<void> {
|
||||
return this.api.setCurrentUserId(userId);
|
||||
return this.api.command({ type: 'set-current-user-id', payload: { userId } });
|
||||
}
|
||||
|
||||
/** Retrieve users associated with a room. */
|
||||
getUsersByRoom(roomId: string): Promise<User[]> {
|
||||
return this.api.getUsersByRoom(roomId);
|
||||
return this.api.query<User[]>({ type: 'get-users-by-room', payload: { roomId } });
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing user. */
|
||||
updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||
return this.api.updateUser(userId, updates);
|
||||
return this.api.command({ type: 'update-user', payload: { userId, updates } });
|
||||
}
|
||||
|
||||
/** Persist a room record. */
|
||||
saveRoom(room: Room): Promise<void> {
|
||||
return this.api.saveRoom(room);
|
||||
return this.api.command({ type: 'save-room', payload: { room } });
|
||||
}
|
||||
|
||||
/** Retrieve a room by ID, or `null` if not found. */
|
||||
getRoom(roomId: string): Promise<Room | null> {
|
||||
return this.api.getRoom(roomId);
|
||||
return this.api.query<Room | null>({ type: 'get-room', payload: { roomId } });
|
||||
}
|
||||
|
||||
/** Return every persisted room. */
|
||||
getAllRooms(): Promise<Room[]> {
|
||||
return this.api.getAllRooms();
|
||||
return this.api.query<Room[]>({ type: 'get-all-rooms', payload: {} });
|
||||
}
|
||||
|
||||
/** Delete a room by ID. */
|
||||
/** Delete a room by ID (also removes its messages). */
|
||||
deleteRoom(roomId: string): Promise<void> {
|
||||
return this.api.deleteRoom(roomId);
|
||||
return this.api.command({ type: 'delete-room', payload: { roomId } });
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing room. */
|
||||
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||
return this.api.updateRoom(roomId, updates);
|
||||
return this.api.command({ type: 'update-room', payload: { roomId, updates } });
|
||||
}
|
||||
|
||||
/** Persist a ban entry. */
|
||||
saveBan(ban: BanEntry): Promise<void> {
|
||||
return this.api.saveBan(ban);
|
||||
return this.api.command({ type: 'save-ban', payload: { ban } });
|
||||
}
|
||||
|
||||
/** Remove a ban by the banned user's `oderId`. */
|
||||
removeBan(oderId: string): Promise<void> {
|
||||
return this.api.removeBan(oderId);
|
||||
return this.api.command({ type: 'remove-ban', payload: { oderId } });
|
||||
}
|
||||
|
||||
/** Return active bans for a room. */
|
||||
getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
||||
return this.api.getBansForRoom(roomId);
|
||||
return this.api.query<BanEntry[]>({ type: 'get-bans-for-room', payload: { roomId } });
|
||||
}
|
||||
|
||||
/** Check whether a user is currently banned from a room. */
|
||||
isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||
return this.api.isUserBanned(userId, roomId);
|
||||
return this.api.query<boolean>({ type: 'is-user-banned', payload: { userId, roomId } });
|
||||
}
|
||||
|
||||
/** Persist attachment metadata. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
saveAttachment(attachment: any): Promise<void> {
|
||||
return this.api.saveAttachment(attachment);
|
||||
return this.api.command({ type: 'save-attachment', payload: { attachment } });
|
||||
}
|
||||
|
||||
/** Return all attachment records for a message. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
||||
return this.api.getAttachmentsForMessage(messageId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return this.api.query<any[]>({ type: 'get-attachments-for-message', payload: { messageId } });
|
||||
}
|
||||
|
||||
/** Return every persisted attachment record. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllAttachments(): Promise<any[]> {
|
||||
return this.api.getAllAttachments();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} });
|
||||
}
|
||||
|
||||
/** Delete all attachment records for a message. */
|
||||
deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
return this.api.deleteAttachmentsForMessage(messageId);
|
||||
return this.api.command({ type: 'delete-attachments-for-message', payload: { messageId } });
|
||||
}
|
||||
|
||||
/** Wipe every table, removing all persisted data. */
|
||||
clearAllData(): Promise<void> {
|
||||
return this.api.clearAllData();
|
||||
return this.api.command({ type: 'clear-all-data', payload: {} });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -67,8 +66,7 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
* Only accessible to users with admin privileges.
|
||||
*/
|
||||
export class AdminPanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
store = inject(Store);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
@@ -95,6 +93,8 @@ export class AdminPanelComponent {
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
private webrtc = inject(WebRTCService);
|
||||
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
const room = this.currentRoom();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
/* eslint-disable max-statements-per-line */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -32,10 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
*/
|
||||
export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
serversSvc = inject(ServerDirectoryService);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
@@ -43,6 +40,10 @@ export class LoginComponent {
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private auth = inject(AuthService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
/* eslint-disable max-statements-per-line */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -32,10 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
* Registration form allowing new users to create an account on a selected server.
|
||||
*/
|
||||
export class RegisterComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
serversSvc = inject(ServerDirectoryService);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
@@ -44,6 +41,10 @@ export class RegisterComponent {
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private auth = inject(AuthService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -26,10 +25,11 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
* Compact user status bar showing the current user with login/register navigation links.
|
||||
*/
|
||||
export class UserBarComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
store = inject(Store);
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
/** Navigate to the specified authentication page. */
|
||||
goto(path: 'login' | 'register') {
|
||||
this.router.navigate([`/${path}`]);
|
||||
|
||||
Reference in New Issue
Block a user