Refacor electron app and add migrations

This commit is contained in:
2026-03-04 01:38:43 +01:00
parent 4e95ae77c5
commit be91b6dfe8
70 changed files with 1824 additions and 923 deletions

2
dev.sh
View File

@@ -33,4 +33,4 @@ fi
exec npx concurrently --kill-others \
"cd server && npm run dev" \
"$NG_SERVE" \
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron ."
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"

19
electron/app/flags.ts Normal file
View File

@@ -0,0 +1,19 @@
import { app } from 'electron';
export function configureAppFlags(): void {
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
if (process.platform === 'linux') {
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
}
// Suppress Autofill devtools errors
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
// Allow media autoplay without user gesture
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
// Accept self-signed certificates in development (for --ssl dev server)
if (process.env['SSL'] === 'true') {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
}

40
electron/app/lifecycle.ts Normal file
View File

@@ -0,0 +1,40 @@
import { app, BrowserWindow } from 'electron';
import {
initializeDatabase,
destroyDatabase,
getDataSource
} from '../db/database';
import { createWindow } from '../window/create-window';
import {
setupCqrsHandlers,
setupSystemHandlers,
setupWindowControlHandlers
} from '../ipc';
export function registerAppLifecycle(): void {
app.whenReady().then(async () => {
await initializeDatabase();
setupCqrsHandlers();
setupWindowControlHandlers();
setupSystemHandlers();
await createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0)
createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin')
app.quit();
});
app.on('before-quit', async (event) => {
if (getDataSource()?.isInitialized) {
event.preventDefault();
await destroyDatabase();
app.quit();
}
});
}

View File

@@ -0,0 +1,20 @@
import { DataSource } from 'typeorm';
import {
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
} from '../../../entities';
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MessageEntity).clear();
await dataSource.getRepository(UserEntity).clear();
await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();
await dataSource.getRepository(MetaEntity).clear();
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { ClearRoomMessagesCommand } from '../../types';
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
await repo.delete({ roomId: command.payload.roomId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { DeleteAttachmentsForMessageCommand } from '../../types';
export async function handleDeleteAttachmentsForMessage(command: DeleteAttachmentsForMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(AttachmentEntity);
await repo.delete({ messageId: command.payload.messageId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { DeleteMessageCommand } from '../../types';
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
await repo.delete({ id: command.payload.messageId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { RoomEntity, MessageEntity } from '../../../entities';
import { DeleteRoomCommand } from '../../types';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload;
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
await dataSource.getRepository(MessageEntity).delete({ roomId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { RemoveBanCommand } from '../../types';
export async function handleRemoveBan(command: RemoveBanCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(BanEntity);
await repo.delete({ oderId: command.payload.oderId });
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { RemoveReactionCommand } from '../../types';
export async function handleRemoveReaction(command: RemoveReactionCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ReactionEntity);
const { messageId, userId, emoji } = command.payload;
await repo.delete({ messageId, userId, emoji });
}

View File

@@ -0,0 +1,21 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { SaveAttachmentCommand } from '../../types';
export async function handleSaveAttachment(command: SaveAttachmentCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(AttachmentEntity);
const { attachment } = command.payload;
const entity = repo.create({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage ? 1 : 0,
uploaderPeerId: attachment.uploaderPeerId ?? null,
filePath: attachment.filePath ?? null,
savedPath: attachment.savedPath ?? null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,20 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { SaveBanCommand } from '../../types';
export async function handleSaveBan(command: SaveBanCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(BanEntity);
const { ban } = command.payload;
const entity = repo.create({
oderId: ban.oderId,
roomId: ban.roomId,
userId: ban.userId ?? null,
bannedBy: ban.bannedBy,
displayName: ban.displayName ?? null,
reason: ban.reason ?? null,
expiresAt: ban.expiresAt ?? null,
timestamp: ban.timestamp
});
await repo.save(entity);
}

View File

@@ -0,0 +1,24 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload;
const entity = repo.create({
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
reactions: JSON.stringify(message.reactions ?? []),
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null,
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { SaveReactionCommand } from '../../types';
export async function handleSaveReaction(command: SaveReactionCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ReactionEntity);
const { reaction } = command.payload;
// Deduplicate: skip if same messageId + userId + emoji already exists
const existing = await repo.findOne({
where: { messageId: reaction.messageId, userId: reaction.userId, emoji: reaction.emoji }
});
if (existing)
return;
const entity = repo.create({
id: reaction.id,
messageId: reaction.messageId,
oderId: reaction.oderId ?? null,
userId: reaction.userId ?? null,
emoji: reaction.emoji,
timestamp: reaction.timestamp
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { SaveRoomCommand } from '../../types';
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { room } = command.payload;
const entity = repo.create({
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
channels: room.channels != null ? JSON.stringify(room.channels) : null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { SaveUserCommand } from '../../types';
export async function handleSaveUser(command: SaveUserCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(UserEntity);
const { user } = command.payload;
const entity = repo.create({
id: user.id,
oderId: user.oderId ?? null,
username: user.username ?? null,
displayName: user.displayName ?? null,
avatarUrl: user.avatarUrl ?? null,
status: user.status ?? null,
role: user.role ?? null,
joinedAt: user.joinedAt ?? null,
peerId: user.peerId ?? null,
isOnline: user.isOnline ? 1 : 0,
isAdmin: user.isAdmin ? 1 : 0,
isRoomOwner: user.isRoomOwner ? 1 : 0,
voiceState: user.voiceState != null ? JSON.stringify(user.voiceState) : null,
screenShareState: user.screenShareState != null ? JSON.stringify(user.screenShareState) : null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { SetCurrentUserIdCommand } from '../../types';
export async function handleSetCurrentUserId(command: SetCurrentUserIdCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MetaEntity);
await repo.save({ key: 'currentUserId', value: command.payload.userId });
}

View File

@@ -0,0 +1,41 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { messageId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: messageId } });
if (!existing)
return;
if (updates.channelId !== undefined)
existing.channelId = updates.channelId ?? null;
if (updates.senderId !== undefined)
existing.senderId = updates.senderId;
if (updates.senderName !== undefined)
existing.senderName = updates.senderName;
if (updates.content !== undefined)
existing.content = updates.content;
if (updates.timestamp !== undefined)
existing.timestamp = updates.timestamp;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.reactions !== undefined)
existing.reactions = JSON.stringify(updates.reactions ?? []);
if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined)
existing.replyToId = updates.replyToId ?? null;
await repo.save(existing);
}

View File

@@ -0,0 +1,29 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { UpdateRoomCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = {
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull,
channels: jsonOrNull
};
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { roomId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: roomId } });
if (!existing)
return;
applyUpdates(existing, updates, ROOM_TRANSFORMS);
await repo.save(existing);
}

View File

@@ -0,0 +1,30 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { UpdateUserCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const USER_TRANSFORMS: TransformMap = {
isOnline: boolToInt,
isAdmin: boolToInt,
isRoomOwner: boolToInt,
voiceState: jsonOrNull,
screenShareState: jsonOrNull
};
export async function handleUpdateUser(command: UpdateUserCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(UserEntity);
const { userId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: userId } });
if (!existing)
return;
applyUpdates(existing, updates, USER_TRANSFORMS);
await repo.save(existing);
}

View File

@@ -0,0 +1,32 @@
/** Converts a boolean-like value to SQLite's 0/1 integer representation. */
export const boolToInt = (val: unknown) => (val ? 1 : 0);
/** Serialises an object to a JSON string, or returns null if the value is null/undefined. */
export const jsonOrNull = (val: unknown) => (val != null ? JSON.stringify(val) : null);
/** A map of field names to transform functions that handle special serialisation. */
export type TransformMap = Partial<Record<string, (val: unknown) => unknown>>;
/**
* Applies a partial `updates` object onto an existing entity.
*
* - Fields absent from `updates` (undefined) are skipped entirely.
* - Fields listed in `transforms` are passed through their transform function first.
* - All other fields are written as-is, falling back to null when the value is null/undefined.
*/
export function applyUpdates<T extends object>(
entity: T,
updates: Partial<Record<string, unknown>>,
transforms: TransformMap = {}
): void {
const target = entity as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
if (value === undefined)
continue;
const transform = transforms[key];
target[key] = transform ? transform(value) : (value ?? null);
}
}

View File

@@ -0,0 +1,59 @@
import { DataSource } from 'typeorm';
import {
CommandType,
CommandTypeKey,
Command,
SaveMessageCommand,
DeleteMessageCommand,
UpdateMessageCommand,
ClearRoomMessagesCommand,
SaveReactionCommand,
RemoveReactionCommand,
SaveUserCommand,
SetCurrentUserIdCommand,
UpdateUserCommand,
SaveRoomCommand,
DeleteRoomCommand,
UpdateRoomCommand,
SaveBanCommand,
RemoveBanCommand,
SaveAttachmentCommand,
DeleteAttachmentsForMessageCommand
} from '../types';
import { handleSaveMessage } from './handlers/saveMessage';
import { handleDeleteMessage } from './handlers/deleteMessage';
import { handleUpdateMessage } from './handlers/updateMessage';
import { handleClearRoomMessages } from './handlers/clearRoomMessages';
import { handleSaveReaction } from './handlers/saveReaction';
import { handleRemoveReaction } from './handlers/removeReaction';
import { handleSaveUser } from './handlers/saveUser';
import { handleSetCurrentUserId } from './handlers/setCurrentUserId';
import { handleUpdateUser } from './handlers/updateUser';
import { handleSaveRoom } from './handlers/saveRoom';
import { handleDeleteRoom } from './handlers/deleteRoom';
import { handleUpdateRoom } from './handlers/updateRoom';
import { handleSaveBan } from './handlers/saveBan';
import { handleRemoveBan } from './handlers/removeBan';
import { handleSaveAttachment } from './handlers/saveAttachment';
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
import { handleClearAllData } from './handlers/clearAllData';
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
[CommandType.SaveMessage]: (cmd) => handleSaveMessage(cmd as SaveMessageCommand, dataSource),
[CommandType.DeleteMessage]: (cmd) => handleDeleteMessage(cmd as DeleteMessageCommand, dataSource),
[CommandType.UpdateMessage]: (cmd) => handleUpdateMessage(cmd as UpdateMessageCommand, dataSource),
[CommandType.ClearRoomMessages]: (cmd) => handleClearRoomMessages(cmd as ClearRoomMessagesCommand, dataSource),
[CommandType.SaveReaction]: (cmd) => handleSaveReaction(cmd as SaveReactionCommand, dataSource),
[CommandType.RemoveReaction]: (cmd) => handleRemoveReaction(cmd as RemoveReactionCommand, dataSource),
[CommandType.SaveUser]: (cmd) => handleSaveUser(cmd as SaveUserCommand, dataSource),
[CommandType.SetCurrentUserId]: (cmd) => handleSetCurrentUserId(cmd as SetCurrentUserIdCommand, dataSource),
[CommandType.UpdateUser]: (cmd) => handleUpdateUser(cmd as UpdateUserCommand, dataSource),
[CommandType.SaveRoom]: (cmd) => handleSaveRoom(cmd as SaveRoomCommand, dataSource),
[CommandType.DeleteRoom]: (cmd) => handleDeleteRoom(cmd as DeleteRoomCommand, dataSource),
[CommandType.UpdateRoom]: (cmd) => handleUpdateRoom(cmd as UpdateRoomCommand, dataSource),
[CommandType.SaveBan]: (cmd) => handleSaveBan(cmd as SaveBanCommand, dataSource),
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
});

103
electron/cqrs/mappers.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Takes TypeORM entity rows and converts them to plain DTO objects
* matching the Angular-side interfaces.
*/
import { MessageEntity } from '../entities/MessageEntity';
import { UserEntity } from '../entities/UserEntity';
import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity';
export function rowToMessage(row: MessageEntity) {
return {
id: row.id,
roomId: row.roomId,
channelId: row.channelId ?? undefined,
senderId: row.senderId,
senderName: row.senderName,
content: row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
reactions: JSON.parse(row.reactions || '[]') as unknown[],
isDeleted: !!row.isDeleted,
replyToId: row.replyToId ?? undefined
};
}
export function rowToUser(row: UserEntity) {
return {
id: row.id,
oderId: row.oderId ?? '',
username: row.username ?? '',
displayName: row.displayName ?? '',
avatarUrl: row.avatarUrl ?? undefined,
status: row.status ?? 'offline',
role: row.role ?? 'member',
joinedAt: row.joinedAt ?? 0,
peerId: row.peerId ?? undefined,
isOnline: !!row.isOnline,
isAdmin: !!row.isAdmin,
isRoomOwner: !!row.isRoomOwner,
voiceState: row.voiceState ? JSON.parse(row.voiceState) : undefined,
screenShareState: row.screenShareState ? JSON.parse(row.screenShareState) : undefined
};
}
export function rowToRoom(row: RoomEntity) {
return {
id: row.id,
name: row.name,
description: row.description ?? undefined,
topic: row.topic ?? undefined,
hostId: row.hostId,
password: row.password ?? undefined,
isPrivate: !!row.isPrivate,
createdAt: row.createdAt,
userCount: row.userCount,
maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
channels: row.channels ? JSON.parse(row.channels) : undefined
};
}
export function rowToReaction(row: ReactionEntity) {
return {
id: row.id,
messageId: row.messageId,
oderId: row.oderId ?? '',
userId: row.userId ?? '',
emoji: row.emoji,
timestamp: row.timestamp
};
}
export function rowToAttachment(row: AttachmentEntity) {
return {
id: row.id,
messageId: row.messageId,
filename: row.filename,
size: row.size,
mime: row.mime,
isImage: !!row.isImage,
uploaderPeerId: row.uploaderPeerId ?? undefined,
filePath: row.filePath ?? undefined,
savedPath: row.savedPath ?? undefined
};
}
export function rowToBan(row: BanEntity) {
return {
oderId: row.oderId,
userId: row.userId ?? '',
roomId: row.roomId,
bannedBy: row.bannedBy,
displayName: row.displayName ?? undefined,
reason: row.reason ?? undefined,
expiresAt: row.expiresAt ?? undefined,
timestamp: row.timestamp
};
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { rowToAttachment } from '../../mappers';
export async function handleGetAllAttachments(dataSource: DataSource) {
const repo = dataSource.getRepository(AttachmentEntity);
const rows = await repo.find();
return rows.map(rowToAttachment);
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { rowToRoom } from '../../mappers';
export async function handleGetAllRooms(dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const rows = await repo.find();
return rows.map(rowToRoom);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { GetAttachmentsForMessageQuery } from '../../types';
import { rowToAttachment } from '../../mappers';
export async function handleGetAttachmentsForMessage(query: GetAttachmentsForMessageQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(AttachmentEntity);
const rows = await repo.find({ where: { messageId: query.payload.messageId } });
return rows.map(rowToAttachment);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { GetBansForRoomQuery } from '../../types';
import { rowToBan } from '../../mappers';
export async function handleGetBansForRoom(query: GetBansForRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(BanEntity);
const now = Date.now();
const rows = await repo
.createQueryBuilder('ban')
.where('ban.roomId = :roomId', { roomId: query.payload.roomId })
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
.getMany();
return rows.map(rowToBan);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { UserEntity, MetaEntity } from '../../../entities';
import { rowToUser } from '../../mappers';
export async function handleGetCurrentUser(dataSource: DataSource) {
const metaRepo = dataSource.getRepository(MetaEntity);
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
if (!metaRow?.value)
return null;
const userRepo = dataSource.getRepository(UserEntity);
const userRow = await userRepo.findOne({ where: { id: metaRow.value } });
return userRow ? rowToUser(userRow) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessageByIdQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const row = await repo.findOne({ where: { id: query.payload.messageId } });
return row ? rowToMessage(row) : null;
}

View File

@@ -0,0 +1,17 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, limit = 100, offset = 0 } = query.payload;
const rows = await repo.find({
where: { roomId },
order: { timestamp: 'ASC' },
take: limit,
skip: offset
});
return rows.map(rowToMessage);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { GetReactionsForMessageQuery } from '../../types';
import { rowToReaction } from '../../mappers';
export async function handleGetReactionsForMessage(query: GetReactionsForMessageQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(ReactionEntity);
const rows = await repo.find({ where: { messageId: query.payload.messageId } });
return rows.map(rowToReaction);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { GetRoomQuery } from '../../types';
import { rowToRoom } from '../../mappers';
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const row = await repo.findOne({ where: { id: query.payload.roomId } });
return row ? rowToRoom(row) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { GetUserQuery } from '../../types';
import { rowToUser } from '../../mappers';
export async function handleGetUser(query: GetUserQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(UserEntity);
const row = await repo.findOne({ where: { id: query.payload.userId } });
return row ? rowToUser(row) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { rowToUser } from '../../mappers';
/** Returns all stored users (room filtering not applicable in this schema). */
export async function handleGetUsersByRoom(dataSource: DataSource) {
const repo = dataSource.getRepository(UserEntity);
const rows = await repo.find();
return rows.map(rowToUser);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { IsUserBannedQuery } from '../../types';
export async function handleIsUserBanned(query: IsUserBannedQuery, dataSource: DataSource): Promise<boolean> {
const repo = dataSource.getRepository(BanEntity);
const now = Date.now();
const { userId, roomId } = query.payload;
const rows = await repo
.createQueryBuilder('ban')
.where('ban.roomId = :roomId', { roomId })
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
.getMany();
return rows.some((row) => row.oderId === userId);
}

View File

@@ -0,0 +1,41 @@
import { DataSource } from 'typeorm';
import {
QueryType,
QueryTypeKey,
Query,
GetMessagesQuery,
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
GetRoomQuery,
GetBansForRoomQuery,
IsUserBannedQuery,
GetAttachmentsForMessageQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser';
import { handleGetCurrentUser } from './handlers/getCurrentUser';
import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
import { handleGetRoom } from './handlers/getRoom';
import { handleGetAllRooms } from './handlers/getAllRooms';
import { handleGetBansForRoom } from './handlers/getBansForRoom';
import { handleIsUserBanned } from './handlers/isUserBanned';
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource)
});

197
electron/cqrs/types.ts Normal file
View File

@@ -0,0 +1,197 @@
/* ------------------------------------------------------------------ */
/* CQRS type definitions for the MetoYou electron main process. */
/* Commands mutate state; queries read state. */
/* ------------------------------------------------------------------ */
// --------------- Command types ---------------
export const CommandType = {
SaveMessage: 'save-message',
DeleteMessage: 'delete-message',
UpdateMessage: 'update-message',
ClearRoomMessages: 'clear-room-messages',
SaveReaction: 'save-reaction',
RemoveReaction: 'remove-reaction',
SaveUser: 'save-user',
SetCurrentUserId: 'set-current-user-id',
UpdateUser: 'update-user',
SaveRoom: 'save-room',
DeleteRoom: 'delete-room',
UpdateRoom: 'update-room',
SaveBan: 'save-ban',
RemoveBan: 'remove-ban',
SaveAttachment: 'save-attachment',
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
ClearAllData: 'clear-all-data'
} as const;
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
// --------------- Query types ---------------
export const QueryType = {
GetMessages: 'get-messages',
GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user',
GetCurrentUser: 'get-current-user',
GetUsersByRoom: 'get-users-by-room',
GetRoom: 'get-room',
GetAllRooms: 'get-all-rooms',
GetBansForRoom: 'get-bans-for-room',
IsUserBanned: 'is-user-banned',
GetAttachmentsForMessage: 'get-attachments-for-message',
GetAllAttachments: 'get-all-attachments'
} as const;
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
// --------------- Payload interfaces ---------------
export interface MessagePayload {
id: string;
roomId: string;
channelId?: string;
senderId: string;
senderName: string;
content: string;
timestamp: number;
editedAt?: number;
reactions?: ReactionPayload[];
isDeleted?: boolean;
replyToId?: string;
}
export interface ReactionPayload {
id: string;
messageId: string;
oderId: string;
userId: string;
emoji: string;
timestamp: number;
}
export interface UserPayload {
id: string;
oderId?: string;
username?: string;
displayName?: string;
avatarUrl?: string;
status?: string;
role?: string;
joinedAt?: number;
peerId?: string;
isOnline?: boolean;
isAdmin?: boolean;
isRoomOwner?: boolean;
voiceState?: unknown;
screenShareState?: unknown;
}
export interface RoomPayload {
id: string;
name: string;
description?: string;
topic?: string;
hostId: string;
password?: string;
isPrivate?: boolean;
createdAt: number;
userCount?: number;
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
permissions?: unknown;
channels?: unknown[];
}
export interface BanPayload {
oderId: string;
roomId: string;
userId?: string;
bannedBy: string;
displayName?: string;
reason?: string;
expiresAt?: number;
timestamp: number;
}
export interface AttachmentPayload {
id: string;
messageId: string;
filename: string;
size: number;
mime: string;
isImage?: boolean;
uploaderPeerId?: string;
filePath?: string;
savedPath?: string;
}
// --------------- Command interfaces ---------------
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
export interface ClearRoomMessagesCommand { type: typeof CommandType.ClearRoomMessages; payload: { roomId: string } }
export interface SaveReactionCommand { type: typeof CommandType.SaveReaction; payload: { reaction: ReactionPayload } }
export interface RemoveReactionCommand { type: typeof CommandType.RemoveReaction; payload: { messageId: string; userId: string; emoji: string } }
export interface SaveUserCommand { type: typeof CommandType.SaveUser; payload: { user: UserPayload } }
export interface SetCurrentUserIdCommand { type: typeof CommandType.SetCurrentUserId; payload: { userId: string } }
export interface UpdateUserCommand { type: typeof CommandType.UpdateUser; payload: { userId: string; updates: Partial<UserPayload> } }
export interface SaveRoomCommand { type: typeof CommandType.SaveRoom; payload: { room: RoomPayload } }
export interface DeleteRoomCommand { type: typeof CommandType.DeleteRoom; payload: { roomId: string } }
export interface UpdateRoomCommand { type: typeof CommandType.UpdateRoom; payload: { roomId: string; updates: Partial<RoomPayload> } }
export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { ban: BanPayload } }
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
export type Command =
| SaveMessageCommand
| DeleteMessageCommand
| UpdateMessageCommand
| ClearRoomMessagesCommand
| SaveReactionCommand
| RemoveReactionCommand
| SaveUserCommand
| SetCurrentUserIdCommand
| UpdateUserCommand
| SaveRoomCommand
| DeleteRoomCommand
| UpdateRoomCommand
| SaveBanCommand
| RemoveBanCommand
| SaveAttachmentCommand
| DeleteAttachmentsForMessageCommand
| ClearAllDataCommand;
// --------------- Query interfaces ---------------
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; payload: { roomId: string } }
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
export type Query =
| GetMessagesQuery
| GetMessageByIdQuery
| GetReactionsForMessageQuery
| GetUserQuery
| GetCurrentUserQuery
| GetUsersByRoomQuery
| GetRoomQuery
| GetAllRoomsQuery
| GetBansForRoomQuery
| IsUserBannedQuery
| GetAttachmentsForMessageQuery
| GetAllAttachmentsQuery;

43
electron/data-source.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* TypeORM DataSource used by the CLI migration tools (generate / run / revert).
*
* At **runtime** the DataSource is constructed inside `main.ts` and
* pointed at the user's Electron `userData` directory instead.
*
* For CLI use, the database is stored at the project root (`metoyou.sqlite`)
* so TypeORM can diff entity metadata against the current schema when
* generating new migrations.
*/
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import * as path from 'path';
import * as fs from 'fs';
import { settings } from './settings';
import {
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
} from './entities';
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
let databaseFileBuffer: Uint8Array | undefined;
if (fs.existsSync(projectRootDatabaseFilePath)) {
databaseFileBuffer = fs.readFileSync(projectRootDatabaseFilePath);
}
export const AppDataSource = new DataSource({
type: 'sqljs',
database: databaseFileBuffer,
entities: [MessageEntity, UserEntity, RoomEntity, ReactionEntity, BanEntity, AttachmentEntity, MetaEntity],
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
synchronize: false,
logging: false,
autoSave: true,
location: projectRootDatabaseFilePath
});

View File

@@ -1,618 +0,0 @@
/**
* Electron main-process SQLite database module.
*
* All SQL queries live here the renderer communicates exclusively via IPC.
* Uses sql.js (WASM SQLite) loaded in Node.js.
*/
const { ipcMain, app } = require('electron');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
let db = null;
let dbPath = '';
/* ------------------------------------------------------------------ */
/* Migrations */
/* ------------------------------------------------------------------ */
const migrations = [
{
version: 1,
description: 'Initial schema messages, users, rooms, reactions, bans, meta',
up(database) {
database.run(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
roomId TEXT NOT NULL,
channelId TEXT,
senderId TEXT NOT NULL,
senderName TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
editedAt INTEGER,
reactions TEXT NOT NULL DEFAULT '[]',
isDeleted INTEGER NOT NULL DEFAULT 0,
replyToId TEXT
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_messages_roomId ON messages(roomId);');
database.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
oderId TEXT,
username TEXT,
displayName TEXT,
avatarUrl TEXT,
status TEXT,
role TEXT,
joinedAt INTEGER,
peerId TEXT,
isOnline INTEGER,
isAdmin INTEGER,
isRoomOwner INTEGER,
voiceState TEXT,
screenShareState TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
topic TEXT,
hostId TEXT NOT NULL,
password TEXT,
isPrivate INTEGER NOT NULL DEFAULT 0,
createdAt INTEGER NOT NULL,
userCount INTEGER NOT NULL DEFAULT 0,
maxUsers INTEGER,
icon TEXT,
iconUpdatedAt INTEGER,
permissions TEXT,
channels TEXT
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS reactions (
id TEXT PRIMARY KEY,
messageId TEXT NOT NULL,
oderId TEXT,
userId TEXT,
emoji TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_reactions_messageId ON reactions(messageId);');
database.run(`
CREATE TABLE IF NOT EXISTS bans (
oderId TEXT NOT NULL,
userId TEXT,
roomId TEXT NOT NULL,
bannedBy TEXT NOT NULL,
displayName TEXT,
reason TEXT,
expiresAt INTEGER,
timestamp INTEGER NOT NULL,
PRIMARY KEY (oderId, roomId)
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_bans_roomId ON bans(roomId);');
database.run(`
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
`);
},
},
{
version: 2,
description: 'Attachments table',
up(database) {
database.run(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
messageId TEXT NOT NULL,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT NOT NULL,
isImage INTEGER NOT NULL DEFAULT 0,
uploaderPeerId TEXT,
filePath TEXT,
savedPath TEXT
);
`);
database.run('CREATE INDEX IF NOT EXISTS idx_attachments_messageId ON attachments(messageId);');
},
},
];
function runMigrations() {
db.run(`
CREATE TABLE IF NOT EXISTS schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL DEFAULT 0
);
`);
const row = db.exec('SELECT version FROM schema_version WHERE id = 1');
let currentVersion = row.length > 0 ? row[0].values[0][0] : 0;
if (row.length === 0) {
db.run('INSERT INTO schema_version (id, version) VALUES (1, 0)');
}
for (const migration of migrations) {
if (migration.version > currentVersion) {
console.log(`[ElectronDB] Running migration v${migration.version}: ${migration.description}`);
migration.up(db);
currentVersion = migration.version;
db.run('UPDATE schema_version SET version = ? WHERE id = 1', [currentVersion]);
}
}
}
/* ------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------ */
function persist() {
if (!db) return;
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
}
/* ------------------------------------------------------------------ */
/* Initialisation */
/* ------------------------------------------------------------------ */
async function initDatabase() {
const initSqlJs = require('sql.js');
const SQL = await initSqlJs();
const dbDir = path.join(app.getPath('userData'), 'metoyou');
await fsp.mkdir(dbDir, { recursive: true });
dbPath = path.join(dbDir, 'metoyou.sqlite');
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
db = new SQL.Database(fileBuffer);
} else {
db = new SQL.Database();
}
db.run('PRAGMA journal_mode = MEMORY;');
db.run('PRAGMA synchronous = NORMAL;');
runMigrations();
persist();
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Run a prepared-statement query and return rows as plain objects. */
function queryAll(sql, params = []) {
const stmt = db.prepare(sql);
stmt.bind(params);
const results = [];
while (stmt.step()) results.push(stmt.getAsObject());
stmt.free();
return results;
}
/** Return a single row as object or null. */
function queryOne(sql, params = []) {
const stmt = db.prepare(sql);
stmt.bind(params);
let result = null;
if (stmt.step()) result = stmt.getAsObject();
stmt.free();
return result;
}
/* ------------------------------------------------------------------ */
/* Row → model mappers */
/* ------------------------------------------------------------------ */
function rowToMessage(r) {
return {
id: String(r.id),
roomId: String(r.roomId),
channelId: r.channelId ? String(r.channelId) : undefined,
senderId: String(r.senderId),
senderName: String(r.senderName),
content: String(r.content),
timestamp: Number(r.timestamp),
editedAt: r.editedAt != null ? Number(r.editedAt) : undefined,
reactions: JSON.parse(String(r.reactions || '[]')),
isDeleted: !!r.isDeleted,
replyToId: r.replyToId ? String(r.replyToId) : undefined,
};
}
function rowToUser(r) {
return {
id: String(r.id),
oderId: String(r.oderId ?? ''),
username: String(r.username ?? ''),
displayName: String(r.displayName ?? ''),
avatarUrl: r.avatarUrl ? String(r.avatarUrl) : undefined,
status: String(r.status ?? 'offline'),
role: String(r.role ?? 'member'),
joinedAt: Number(r.joinedAt ?? 0),
peerId: r.peerId ? String(r.peerId) : undefined,
isOnline: !!r.isOnline,
isAdmin: !!r.isAdmin,
isRoomOwner: !!r.isRoomOwner,
voiceState: r.voiceState ? JSON.parse(String(r.voiceState)) : undefined,
screenShareState: r.screenShareState ? JSON.parse(String(r.screenShareState)) : undefined,
};
}
function rowToRoom(r) {
return {
id: String(r.id),
name: String(r.name),
description: r.description ? String(r.description) : undefined,
topic: r.topic ? String(r.topic) : undefined,
hostId: String(r.hostId),
password: r.password ? String(r.password) : undefined,
isPrivate: !!r.isPrivate,
createdAt: Number(r.createdAt),
userCount: Number(r.userCount),
maxUsers: r.maxUsers != null ? Number(r.maxUsers) : undefined,
icon: r.icon ? String(r.icon) : undefined,
iconUpdatedAt: r.iconUpdatedAt != null ? Number(r.iconUpdatedAt) : undefined,
permissions: r.permissions ? JSON.parse(String(r.permissions)) : undefined,
channels: r.channels ? JSON.parse(String(r.channels)) : undefined,
};
}
function rowToReaction(r) {
return {
id: String(r.id),
messageId: String(r.messageId),
oderId: String(r.oderId ?? ''),
userId: String(r.userId ?? ''),
emoji: String(r.emoji),
timestamp: Number(r.timestamp),
};
}
function rowToAttachment(r) {
return {
id: String(r.id),
messageId: String(r.messageId),
filename: String(r.filename),
size: Number(r.size),
mime: String(r.mime),
isImage: !!r.isImage,
uploaderPeerId: r.uploaderPeerId ? String(r.uploaderPeerId) : undefined,
filePath: r.filePath ? String(r.filePath) : undefined,
savedPath: r.savedPath ? String(r.savedPath) : undefined,
};
}
function rowToBan(r) {
return {
oderId: String(r.oderId),
userId: String(r.userId ?? ''),
roomId: String(r.roomId),
bannedBy: String(r.bannedBy),
displayName: r.displayName ? String(r.displayName) : undefined,
reason: r.reason ? String(r.reason) : undefined,
expiresAt: r.expiresAt != null ? Number(r.expiresAt) : undefined,
timestamp: Number(r.timestamp),
};
}
/* ------------------------------------------------------------------ */
/* IPC handler registration */
/* ------------------------------------------------------------------ */
function registerDatabaseIpc() {
ipcMain.handle('db:initialize', async () => {
await initDatabase();
return true;
});
ipcMain.handle('db:saveMessage', (_e, message) => {
db.run(
`INSERT OR REPLACE INTO messages
(id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
message.id,
message.roomId,
message.channelId ?? null,
message.senderId,
message.senderName,
message.content,
message.timestamp,
message.editedAt ?? null,
JSON.stringify(message.reactions ?? []),
message.isDeleted ? 1 : 0,
message.replyToId ?? null,
],
);
persist();
});
ipcMain.handle('db:getMessages', (_e, roomId, limit = 100, offset = 0) => {
const rows = queryAll(
'SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?',
[roomId, limit, offset],
);
return rows.map(rowToMessage);
});
ipcMain.handle('db:deleteMessage', (_e, messageId) => {
db.run('DELETE FROM messages WHERE id = ?', [messageId]);
persist();
});
ipcMain.handle('db:updateMessage', (_e, messageId, updates) => {
const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
if (!row) return;
const msg = { ...rowToMessage(row), ...updates };
db.run(
`INSERT OR REPLACE INTO messages
(id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
msg.id, msg.roomId, msg.channelId ?? null, msg.senderId, msg.senderName,
msg.content, msg.timestamp, msg.editedAt ?? null,
JSON.stringify(msg.reactions ?? []), msg.isDeleted ? 1 : 0, msg.replyToId ?? null,
],
);
persist();
});
ipcMain.handle('db:getMessageById', (_e, messageId) => {
const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
return row ? rowToMessage(row) : null;
});
ipcMain.handle('db:clearRoomMessages', (_e, roomId) => {
db.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
persist();
});
ipcMain.handle('db:saveReaction', (_e, reaction) => {
const check = db.exec(
'SELECT 1 FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
[reaction.messageId, reaction.userId, reaction.emoji],
);
if (check.length > 0 && check[0].values.length > 0) return;
db.run(
`INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp)
VALUES (?, ?, ?, ?, ?, ?)`,
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp],
);
persist();
});
ipcMain.handle('db:removeReaction', (_e, messageId, userId, emoji) => {
db.run('DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?', [messageId, userId, emoji]);
persist();
});
ipcMain.handle('db:getReactionsForMessage', (_e, messageId) => {
const rows = queryAll('SELECT * FROM reactions WHERE messageId = ?', [messageId]);
return rows.map(rowToReaction);
});
ipcMain.handle('db:saveUser', (_e, user) => {
db.run(
`INSERT OR REPLACE INTO users
(id, oderId, username, displayName, avatarUrl, status, role, joinedAt,
peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
user.id,
user.oderId ?? null,
user.username ?? null,
user.displayName ?? null,
user.avatarUrl ?? null,
user.status ?? null,
user.role ?? null,
user.joinedAt ?? null,
user.peerId ?? null,
user.isOnline ? 1 : 0,
user.isAdmin ? 1 : 0,
user.isRoomOwner ? 1 : 0,
user.voiceState ? JSON.stringify(user.voiceState) : null,
user.screenShareState ? JSON.stringify(user.screenShareState) : null,
],
);
persist();
});
ipcMain.handle('db:getUser', (_e, userId) => {
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return row ? rowToUser(row) : null;
});
ipcMain.handle('db:getCurrentUser', () => {
const rows = db.exec("SELECT value FROM meta WHERE key = 'currentUserId'");
if (rows.length === 0 || rows[0].values.length === 0) return null;
const userId = String(rows[0].values[0][0]);
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return row ? rowToUser(row) : null;
});
ipcMain.handle('db:setCurrentUserId', (_e, userId) => {
db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)", [userId]);
persist();
});
ipcMain.handle('db:getUsersByRoom', (_e, _roomId) => {
const rows = queryAll('SELECT * FROM users');
return rows.map(rowToUser);
});
ipcMain.handle('db:updateUser', (_e, userId, updates) => {
const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
if (!row) return;
const user = { ...rowToUser(row), ...updates };
db.run(
`INSERT OR REPLACE INTO users
(id, oderId, username, displayName, avatarUrl, status, role, joinedAt,
peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
user.id, user.oderId ?? null, user.username ?? null, user.displayName ?? null,
user.avatarUrl ?? null, user.status ?? null, user.role ?? null, user.joinedAt ?? null,
user.peerId ?? null, user.isOnline ? 1 : 0, user.isAdmin ? 1 : 0, user.isRoomOwner ? 1 : 0,
user.voiceState ? JSON.stringify(user.voiceState) : null,
user.screenShareState ? JSON.stringify(user.screenShareState) : null,
],
);
persist();
});
ipcMain.handle('db:saveRoom', (_e, room) => {
db.run(
`INSERT OR REPLACE INTO rooms
(id, name, description, topic, hostId, password, isPrivate, createdAt,
userCount, maxUsers, icon, iconUpdatedAt, permissions, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id, room.name, room.description ?? null, room.topic ?? null,
room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt,
room.userCount, room.maxUsers ?? null, room.icon ?? null,
room.iconUpdatedAt ?? null,
room.permissions ? JSON.stringify(room.permissions) : null,
room.channels ? JSON.stringify(room.channels) : null,
],
);
persist();
});
ipcMain.handle('db:getRoom', (_e, roomId) => {
const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
return row ? rowToRoom(row) : null;
});
ipcMain.handle('db:getAllRooms', () => {
const rows = queryAll('SELECT * FROM rooms');
return rows.map(rowToRoom);
});
ipcMain.handle('db:deleteRoom', (_e, roomId) => {
db.run('DELETE FROM rooms WHERE id = ?', [roomId]);
db.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
persist();
});
ipcMain.handle('db:updateRoom', (_e, roomId, updates) => {
const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
if (!row) return;
const room = { ...rowToRoom(row), ...updates };
db.run(
`INSERT OR REPLACE INTO rooms
(id, name, description, topic, hostId, password, isPrivate, createdAt,
userCount, maxUsers, icon, iconUpdatedAt, permissions, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id, room.name, room.description ?? null, room.topic ?? null,
room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt,
room.userCount, room.maxUsers ?? null, room.icon ?? null,
room.iconUpdatedAt ?? null,
room.permissions ? JSON.stringify(room.permissions) : null,
room.channels ? JSON.stringify(room.channels) : null,
],
);
persist();
});
ipcMain.handle('db:saveBan', (_e, ban) => {
db.run(
`INSERT OR REPLACE INTO bans
(oderId, userId, roomId, bannedBy, displayName, reason, expiresAt, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
ban.oderId, ban.userId ?? null, ban.roomId, ban.bannedBy,
ban.displayName ?? null, ban.reason ?? null, ban.expiresAt ?? null, ban.timestamp,
],
);
persist();
});
ipcMain.handle('db:removeBan', (_e, oderId) => {
db.run('DELETE FROM bans WHERE oderId = ?', [oderId]);
persist();
});
ipcMain.handle('db:getBansForRoom', (_e, roomId) => {
const now = Date.now();
const rows = queryAll(
'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)',
[roomId, now],
);
return rows.map(rowToBan);
});
ipcMain.handle('db:isUserBanned', (_e, userId, roomId) => {
const now = Date.now();
const rows = queryAll(
'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)',
[roomId, now],
);
return rows.some((r) => String(r.oderId) === userId);
});
ipcMain.handle('db:saveAttachment', (_e, attachment) => {
db.run(
`INSERT OR REPLACE INTO attachments
(id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
attachment.id, attachment.messageId, attachment.filename,
attachment.size, attachment.mime, attachment.isImage ? 1 : 0,
attachment.uploaderPeerId ?? null, attachment.filePath ?? null,
attachment.savedPath ?? null,
],
);
persist();
});
ipcMain.handle('db:getAttachmentsForMessage', (_e, messageId) => {
const rows = queryAll('SELECT * FROM attachments WHERE messageId = ?', [messageId]);
return rows.map(rowToAttachment);
});
ipcMain.handle('db:getAllAttachments', () => {
const rows = queryAll('SELECT * FROM attachments');
return rows.map(rowToAttachment);
});
ipcMain.handle('db:deleteAttachmentsForMessage', (_e, messageId) => {
db.run('DELETE FROM attachments WHERE messageId = ?', [messageId]);
persist();
});
ipcMain.handle('db:clearAllData', () => {
db.run('DELETE FROM messages');
db.run('DELETE FROM users');
db.run('DELETE FROM rooms');
db.run('DELETE FROM reactions');
db.run('DELETE FROM bans');
db.run('DELETE FROM attachments');
db.run('DELETE FROM meta');
persist();
});
}
module.exports = { registerDatabaseIpc };

76
electron/db/database.ts Normal file
View File

@@ -0,0 +1,76 @@
import { app } from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { DataSource } from 'typeorm';
import {
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
} from '../entities';
import { settings } from '../settings';
let applicationDataSource: DataSource | undefined;
export function getDataSource(): DataSource | undefined {
return applicationDataSource;
}
export async function initializeDatabase(): Promise<void> {
const userDataPath = app.getPath('userData');
const dbDir = path.join(userDataPath, 'metoyou');
await fsp.mkdir(dbDir, { recursive: true });
const databaseFilePath = path.join(dbDir, settings.databaseName);
let database: Uint8Array | undefined;
if (fs.existsSync(databaseFilePath)) {
database = fs.readFileSync(databaseFilePath);
}
applicationDataSource = new DataSource({
type: 'sqljs',
database,
entities: [MessageEntity, UserEntity, RoomEntity, ReactionEntity, BanEntity, AttachmentEntity, MetaEntity],
migrations: [
path.join(__dirname, '..', 'migrations', '*.js'),
path.join(__dirname, '..', 'migrations', '*.ts')
],
synchronize: false,
logging: false,
autoSave: true,
location: databaseFilePath
});
try {
await applicationDataSource.initialize();
console.log('[DB] Connection initialised at:', databaseFilePath);
try {
await applicationDataSource.runMigrations();
console.log('[DB] Migrations executed');
} catch (migErr) {
console.error('[DB] Migration error:', migErr);
}
} catch (error) {
console.error('[DB] Initialisation error:', error);
}
}
export async function destroyDatabase(): Promise<void> {
if (applicationDataSource?.isInitialized) {
try {
await applicationDataSource.destroy();
console.log('[DB] Connection closed');
} catch (error) {
console.error('[DB] Error closing connection:', error);
} finally {
applicationDataSource = undefined;
}
}
}

View File

@@ -0,0 +1,35 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('attachments')
export class AttachmentEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
messageId!: string;
@Column('text')
filename!: string;
@Column('integer')
size!: number;
@Column('text')
mime!: string;
@Column('integer', { default: 0 })
isImage!: number;
@Column('text', { nullable: true })
uploaderPeerId!: string | null;
@Column('text', { nullable: true })
filePath!: string | null;
@Column('text', { nullable: true })
savedPath!: string | null;
}

View File

@@ -0,0 +1,32 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('bans')
export class BanEntity {
@PrimaryColumn('text')
oderId!: string;
@PrimaryColumn('text')
roomId!: string;
@Column('text', { nullable: true })
userId!: string | null;
@Column('text')
bannedBy!: string;
@Column('text', { nullable: true })
displayName!: string | null;
@Column('text', { nullable: true })
reason!: string | null;
@Column('integer', { nullable: true })
expiresAt!: number | null;
@Column('integer')
timestamp!: number;
}

View File

@@ -0,0 +1,41 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('messages')
export class MessageEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
roomId!: string;
@Column('text', { nullable: true })
channelId!: string | null;
@Column('text')
senderId!: string;
@Column('text')
senderName!: string;
@Column('text')
content!: string;
@Column('integer')
timestamp!: number;
@Column('integer', { nullable: true })
editedAt!: number | null;
@Column('text', { default: '[]' })
reactions!: string;
@Column('integer', { default: 0 })
isDeleted!: number;
@Column('text', { nullable: true })
replyToId!: string | null;
}

View File

@@ -0,0 +1,10 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('meta')
export class MetaEntity {
@PrimaryColumn('text')
key!: string;
@Column('text', { nullable: true })
value!: string | null;
}

View File

@@ -0,0 +1,22 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('reactions')
export class ReactionEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
messageId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
@Column('text', { nullable: true })
userId!: string | null;
@Column('text')
emoji!: string;
@Column('integer')
timestamp!: number;
}

View File

@@ -0,0 +1,50 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('rooms')
export class RoomEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
description!: string | null;
@Column('text', { nullable: true })
topic!: string | null;
@Column('text')
hostId!: string;
@Column('text', { nullable: true })
password!: string | null;
@Column('integer', { default: 0 })
isPrivate!: number;
@Column('integer')
createdAt!: number;
@Column('integer', { default: 0 })
userCount!: number;
@Column('integer', { nullable: true })
maxUsers!: number | null;
@Column('text', { nullable: true })
icon!: string | null;
@Column('integer', { nullable: true })
iconUpdatedAt!: number | null;
@Column('text', { nullable: true })
permissions!: string | null;
@Column('text', { nullable: true })
channels!: string | null;
}

View File

@@ -0,0 +1,50 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryColumn('text')
id!: string;
@Column('text', { nullable: true })
oderId!: string | null;
@Column('text', { nullable: true })
username!: string | null;
@Column('text', { nullable: true })
displayName!: string | null;
@Column('text', { nullable: true })
avatarUrl!: string | null;
@Column('text', { nullable: true })
status!: string | null;
@Column('text', { nullable: true })
role!: string | null;
@Column('integer', { nullable: true })
joinedAt!: number | null;
@Column('text', { nullable: true })
peerId!: string | null;
@Column('integer', { default: 0 })
isOnline!: number;
@Column('integer', { default: 0 })
isAdmin!: number;
@Column('integer', { default: 0 })
isRoomOwner!: number;
@Column('text', { nullable: true })
voiceState!: string | null;
@Column('text', { nullable: true })
screenShareState!: string | null;
}

View File

@@ -0,0 +1,7 @@
export { MessageEntity } from './MessageEntity';
export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity';
export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity';
export { MetaEntity } from './MetaEntity';

39
electron/ipc/cqrs.ts Normal file
View File

@@ -0,0 +1,39 @@
import { ipcMain } from 'electron';
import { buildCommandHandlers } from '../cqrs/commands';
import { buildQueryHandlers } from '../cqrs/queries';
import {
Command,
Query,
CommandTypeKey,
QueryTypeKey
} from '../cqrs/types';
import { getDataSource } from '../db/database';
export function setupCqrsHandlers(): void {
const dataSource = getDataSource();
if (!dataSource) {
throw new Error('DataSource not initialised');
}
const commandHandlerMap = buildCommandHandlers(dataSource) as Record<CommandTypeKey, (command: Command) => unknown>;
const queryHandlerMap = buildQueryHandlers(dataSource) as Record<QueryTypeKey, (query: Query) => unknown>;
ipcMain.handle('cqrs:command', async (_evt, command: Command) => {
const handler = commandHandlerMap[command.type as CommandTypeKey];
if (!handler)
throw new Error(`No command handler for type: ${command.type}`);
return handler(command);
});
ipcMain.handle('cqrs:query', async (_evt, query: Query) => {
const handler = queryHandlerMap[query.type as QueryTypeKey];
if (!handler)
throw new Error(`No query handler for type: ${query.type}`);
return handler(query);
});
}

3
electron/ipc/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { setupCqrsHandlers } from './cqrs';
export { setupSystemHandlers } from './system';
export { setupWindowControlHandlers } from './window-controls';

61
electron/ipc/system.ts Normal file
View File

@@ -0,0 +1,61 @@
import {
app,
desktopCapturer,
ipcMain,
shell
} from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
export function setupSystemHandlers(): void {
ipcMain.handle('open-external', async (_event, url: string) => {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
await shell.openExternal(url);
return true;
}
return false;
});
ipcMain.handle('get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnail: source.thumbnail.toDataURL()
}));
});
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('file-exists', async (_event, filePath: string) => {
try {
await fsp.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
});
ipcMain.handle('read-file', async (_event, filePath: string) => {
const data = await fsp.readFile(filePath);
return data.toString('base64');
});
ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => {
const buffer = Buffer.from(base64Data, 'base64');
await fsp.writeFile(filePath, buffer);
return true;
});
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
await fsp.mkdir(dirPath, { recursive: true });
return true;
});
}

View File

@@ -0,0 +1,21 @@
import { ipcMain } from 'electron';
import { getMainWindow } from '../window/create-window';
export function setupWindowControlHandlers(): void {
ipcMain.on('window-minimize', () => {
getMainWindow()?.minimize();
});
ipcMain.on('window-maximize', () => {
const win = getMainWindow();
if (win?.isMaximized())
win.unmaximize();
else
win?.maximize();
});
ipcMain.on('window-close', () => {
getMainWindow()?.close();
});
}

View File

@@ -1,156 +0,0 @@
const { app, BrowserWindow, ipcMain, desktopCapturer, shell } = require('electron');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { registerDatabaseIpc } = require('./database');
let mainWindow;
// Suppress Autofill devtools errors by disabling related features
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
// Allow media autoplay without user gesture (bypasses Chromium autoplay policy)
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
// Accept self-signed certificates in development (for --ssl dev server)
if (process.env.SSL === 'true') {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0a0a0f',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
webSecurity: true,
},
});
// In development, load from Angular dev server
if (process.env.NODE_ENV === 'development') {
const devUrl = process.env.SSL === 'true'
? 'https://localhost:4200'
: 'http://localhost:4200';
mainWindow.loadURL(devUrl);
if (process.env.DEBUG_DEVTOOLS === '1') {
mainWindow.webContents.openDevTools();
}
} else {
// In production, load the built Angular app
// The dist folder is at the project root, not in electron folder
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'client', 'browser', 'index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
// Force all external links to open in the system default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
// Allow navigation to the app itself (dev server or file://)
const currentUrl = mainWindow.webContents.getURL();
const isSameOrigin = new URL(url).origin === new URL(currentUrl).origin;
if (!isSameOrigin) {
event.preventDefault();
shell.openExternal(url);
}
});
}
// Register database IPC handlers before app is ready
registerDatabaseIpc();
// IPC handler for opening URLs in the system default browser
ipcMain.handle('open-external', async (_event, url) => {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
await shell.openExternal(url);
return true;
}
return false;
});
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// IPC handlers for window controls
ipcMain.on('window-minimize', () => {
mainWindow?.minimize();
});
ipcMain.on('window-maximize', () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.on('window-close', () => {
mainWindow?.close();
});
// IPC handler for desktop capturer (screen sharing)
ipcMain.handle('get-sources', async () => {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 },
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnail: source.thumbnail.toDataURL(),
}));
});
// IPC handler for app data path
ipcMain.handle('get-app-data-path', () => {
return app.getPath('userData');
});
// IPC for basic file operations used by renderer
ipcMain.handle('file-exists', async (_event, filePath) => {
try {
await fsp.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
});
ipcMain.handle('read-file', async (_event, filePath) => {
const data = await fsp.readFile(filePath);
return data.toString('base64');
});
ipcMain.handle('write-file', async (_event, filePath, base64Data) => {
const buffer = Buffer.from(base64Data, 'base64');
await fsp.writeFile(filePath, buffer);
return true;
});
ipcMain.handle('ensure-dir', async (_event, dirPath) => {
await fsp.mkdir(dirPath, { recursive: true });
return true;
});

6
electron/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import 'reflect-metadata';
import { configureAppFlags } from './app/flags';
import { registerAppLifecycle } from './app/lifecycle';
configureAppFlags();
registerAppLifecycle();

View File

@@ -0,0 +1,121 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1000000000000 implements MigrationInterface {
name = 'InitialSchema1000000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "messages" (
"id" TEXT PRIMARY KEY NOT NULL,
"roomId" TEXT NOT NULL,
"channelId" TEXT,
"senderId" TEXT NOT NULL,
"senderName" TEXT NOT NULL,
"content" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL,
"editedAt" INTEGER,
"reactions" TEXT NOT NULL DEFAULT '[]',
"isDeleted" INTEGER NOT NULL DEFAULT 0,
"replyToId" TEXT
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "users" (
"id" TEXT PRIMARY KEY NOT NULL,
"oderId" TEXT,
"username" TEXT,
"displayName" TEXT,
"avatarUrl" TEXT,
"status" TEXT,
"role" TEXT,
"joinedAt" INTEGER,
"peerId" TEXT,
"isOnline" INTEGER NOT NULL DEFAULT 0,
"isAdmin" INTEGER NOT NULL DEFAULT 0,
"isRoomOwner" INTEGER NOT NULL DEFAULT 0,
"voiceState" TEXT,
"screenShareState" TEXT
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "rooms" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"permissions" TEXT,
"channels" TEXT
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "reactions" (
"id" TEXT PRIMARY KEY NOT NULL,
"messageId" TEXT NOT NULL,
"oderId" TEXT,
"userId" TEXT,
"emoji" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_reactions_messageId" ON "reactions" ("messageId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "bans" (
"oderId" TEXT NOT NULL,
"roomId" TEXT NOT NULL,
"userId" TEXT,
"bannedBy" TEXT NOT NULL,
"displayName" TEXT,
"reason" TEXT,
"expiresAt" INTEGER,
"timestamp" INTEGER NOT NULL,
PRIMARY KEY ("oderId", "roomId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_bans_roomId" ON "bans" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "attachments" (
"id" TEXT PRIMARY KEY NOT NULL,
"messageId" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"mime" TEXT NOT NULL,
"isImage" INTEGER NOT NULL DEFAULT 0,
"uploaderPeerId" TEXT,
"filePath" TEXT,
"savedPath" TEXT
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_attachments_messageId" ON "attachments" ("messageId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "meta" (
"key" TEXT PRIMARY KEY NOT NULL,
"value" TEXT
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "meta"`);
await queryRunner.query(`DROP TABLE IF EXISTS "attachments"`);
await queryRunner.query(`DROP TABLE IF EXISTS "bans"`);
await queryRunner.query(`DROP TABLE IF EXISTS "reactions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "rooms"`);
await queryRunner.query(`DROP TABLE IF EXISTS "users"`);
await queryRunner.query(`DROP TABLE IF EXISTS "messages"`);
}
}

View File

@@ -1,70 +0,0 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// Window controls
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
// Open URL in system default browser
openExternal: (url) => ipcRenderer.invoke('open-external', url),
// Desktop capturer for screen sharing
getSources: () => ipcRenderer.invoke('get-sources'),
// App data path for SQLite storage
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
// File system operations for database persistence
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
db: {
initialize: () => ipcRenderer.invoke('db:initialize'),
// Messages
saveMessage: (message) => ipcRenderer.invoke('db:saveMessage', message),
getMessages: (roomId, limit, offset) => ipcRenderer.invoke('db:getMessages', roomId, limit, offset),
deleteMessage: (messageId) => ipcRenderer.invoke('db:deleteMessage', messageId),
updateMessage: (messageId, updates) => ipcRenderer.invoke('db:updateMessage', messageId, updates),
getMessageById: (messageId) => ipcRenderer.invoke('db:getMessageById', messageId),
clearRoomMessages: (roomId) => ipcRenderer.invoke('db:clearRoomMessages', roomId),
// Reactions
saveReaction: (reaction) => ipcRenderer.invoke('db:saveReaction', reaction),
removeReaction: (messageId, userId, emoji) => ipcRenderer.invoke('db:removeReaction', messageId, userId, emoji),
getReactionsForMessage: (messageId) => ipcRenderer.invoke('db:getReactionsForMessage', messageId),
// Users
saveUser: (user) => ipcRenderer.invoke('db:saveUser', user),
getUser: (userId) => ipcRenderer.invoke('db:getUser', userId),
getCurrentUser: () => ipcRenderer.invoke('db:getCurrentUser'),
setCurrentUserId: (userId) => ipcRenderer.invoke('db:setCurrentUserId', userId),
getUsersByRoom: (roomId) => ipcRenderer.invoke('db:getUsersByRoom', roomId),
updateUser: (userId, updates) => ipcRenderer.invoke('db:updateUser', userId, updates),
// Rooms
saveRoom: (room) => ipcRenderer.invoke('db:saveRoom', room),
getRoom: (roomId) => ipcRenderer.invoke('db:getRoom', roomId),
getAllRooms: () => ipcRenderer.invoke('db:getAllRooms'),
deleteRoom: (roomId) => ipcRenderer.invoke('db:deleteRoom', roomId),
updateRoom: (roomId, updates) => ipcRenderer.invoke('db:updateRoom', roomId, updates),
// Bans
saveBan: (ban) => ipcRenderer.invoke('db:saveBan', ban),
removeBan: (oderId) => ipcRenderer.invoke('db:removeBan', oderId),
getBansForRoom: (roomId) => ipcRenderer.invoke('db:getBansForRoom', roomId),
isUserBanned: (userId, roomId) => ipcRenderer.invoke('db:isUserBanned', userId, roomId),
// Attachments
saveAttachment: (attachment) => ipcRenderer.invoke('db:saveAttachment', attachment),
getAttachmentsForMessage: (messageId) => ipcRenderer.invoke('db:getAttachmentsForMessage', messageId),
getAllAttachments: () => ipcRenderer.invoke('db:getAllAttachments'),
deleteAttachmentsForMessage: (messageId) => ipcRenderer.invoke('db:deleteAttachmentsForMessage', messageId),
// Utilities
clearAllData: () => ipcRenderer.invoke('db:clearAllData'),
},
});

47
electron/preload.ts Normal file
View File

@@ -0,0 +1,47 @@
import { contextBridge, ipcRenderer } from 'electron';
import { Command, Query } from './cqrs/types';
export interface ElectronAPI {
// Window controls
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
// System utilities
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getAppDataPath: () => Promise<string>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
fileExists: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
// CQRS database operations
command: <T = unknown>(command: Command) => Promise<T>;
query: <T = unknown>(query: Query) => Promise<T>;
}
const electronAPI: ElectronAPI = {
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
getSources: () => ipcRenderer.invoke('get-sources'),
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
command: (command) => ipcRenderer.invoke('cqrs:command', command),
query: (query) => ipcRenderer.invoke('cqrs:query', query)
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

4
electron/settings.ts Normal file
View File

@@ -0,0 +1,4 @@
export const settings = {
databaseName: 'metoyou.sqlite',
debugMode: false
};

7
electron/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.electron.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./**/*"]
}

View File

@@ -0,0 +1,59 @@
import { BrowserWindow, shell } from 'electron';
import * as path from 'path';
let mainWindow: BrowserWindow | null = null;
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
export async function createWindow(): Promise<void> {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0a0a0f',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '..', 'preload.js'),
webSecurity: true
}
});
if (process.env['NODE_ENV'] === 'development') {
const devUrl = process.env['SSL'] === 'true'
? 'https://localhost:4200'
: 'http://localhost:4200';
await mainWindow.loadURL(devUrl);
if (process.env['DEBUG_DEVTOOLS'] === '1') {
mainWindow.webContents.openDevTools();
}
} else {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
const currentUrl = mainWindow?.webContents.getURL();
const isSameOrigin = new URL(url).origin === new URL(currentUrl || '').origin;
if (!isSameOrigin) {
event.preventDefault();
shell.openExternal(url);
}
});
}

View File

@@ -2,7 +2,7 @@
"name": "metoyou",
"version": "1.0.0",
"description": "P2P Discord-like chat application",
"main": "electron/main.js",
"main": "dist/electron/main.js",
"scripts": {
"ng": "ng",
"prebuild": "npm run bundle:rnnoise",
@@ -10,18 +10,23 @@
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
"start": "ng serve",
"build": "ng build",
"build:all": "npm run build && cd server && npm run build",
"build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "ng build --configuration production --base-href='./'",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"server:build": "cd server && npm run build",
"server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev",
"electron": "ng build && electron .",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"",
"electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron .\"",
"electron:build": "npm run build:prod && electron-builder",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
"migration:create": "typeorm migration:create electron/migrations/New",
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
"migration:revert": "typeorm migration:revert -d dist/electron/data-source.js",
"electron:build": "npm run build:prod && npm run build:electron && electron-builder",
"electron:build:win": "npm run build:prod && electron-builder --win",
"electron:build:mac": "npm run build:prod && electron-builder --mac",
"electron:build:linux": "npm run build:prod && electron-builder --linux",
@@ -58,6 +63,7 @@
"clsx": "^2.1.1",
"mermaid": "^11.12.3",
"ngx-remark": "^0.2.2",
"reflect-metadata": "^0.2.2",
"remark": "^15.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
@@ -65,6 +71,7 @@
"simple-peer": "^9.11.1",
"sql.js": "^1.13.0",
"tslib": "^2.3.0",
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -102,7 +109,7 @@
},
"files": [
"dist/client/**/*",
"electron/**/*",
"dist/electron/**/*",
"node_modules/**/*",
"!node_modules/**/test/**/*",
"!node_modules/**/tests/**/*",

Binary file not shown.

View File

@@ -19,7 +19,7 @@ const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// In-memory runtime state (WebSocket connections only not persisted)
// In-memory runtime state (WebSocket connections only - not persisted)
interface ConnectedUser {
oderId: string;
ws: WebSocket;
@@ -49,8 +49,10 @@ import {
JoinRequest
} from './db';
function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw)
.digest('hex'); }
function hashPassword(pw: string) {
return crypto.createHash('sha256')
.update(pw)
.digest('hex'); }
// REST API Routes
@@ -476,6 +478,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
serverId: viewSid,
users: viewUsers
}));
break;
}
@@ -514,6 +517,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
...message,
fromUserId: user.oderId
}));
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
} else {
console.log(`Target user ${message.targetUserId} not found. Connected users:`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

19
tsconfig.electron.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist/electron",
"rootDir": "./electron",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["electron/**/*"],
"exclude": ["node_modules", "dist"]
}