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