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 \
|
exec npx concurrently --kill-others \
|
||||||
"cd server && npm run dev" \
|
"cd server && npm run dev" \
|
||||||
"$NG_SERVE" \
|
"$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",
|
"name": "metoyou",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "P2P Discord-like chat application",
|
"description": "P2P Discord-like chat application",
|
||||||
"main": "electron/main.js",
|
"main": "dist/electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"prebuild": "npm run bundle:rnnoise",
|
"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",
|
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"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='./'",
|
"build:prod": "ng build --configuration production --base-href='./'",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "ng build && 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 && cross-env NODE_ENV=development electron .\"",
|
"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": "./dev.sh",
|
||||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron .\"",
|
"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\"",
|
||||||
"electron:build": "npm run build:prod && electron-builder",
|
"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:win": "npm run build:prod && electron-builder --win",
|
||||||
"electron:build:mac": "npm run build:prod && electron-builder --mac",
|
"electron:build:mac": "npm run build:prod && electron-builder --mac",
|
||||||
"electron:build:linux": "npm run build:prod && electron-builder --linux",
|
"electron:build:linux": "npm run build:prod && electron-builder --linux",
|
||||||
@@ -58,6 +63,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"ngx-remark": "^0.2.2",
|
"ngx-remark": "^0.2.2",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@@ -65,6 +71,7 @@
|
|||||||
"simple-peer": "^9.11.1",
|
"simple-peer": "^9.11.1",
|
||||||
"sql.js": "^1.13.0",
|
"sql.js": "^1.13.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
"typeorm": "^0.3.28",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -102,7 +109,7 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/client/**/*",
|
"dist/client/**/*",
|
||||||
"electron/**/*",
|
"dist/electron/**/*",
|
||||||
"node_modules/**/*",
|
"node_modules/**/*",
|
||||||
"!node_modules/**/test/**/*",
|
"!node_modules/**/test/**/*",
|
||||||
"!node_modules/**/tests/**/*",
|
"!node_modules/**/tests/**/*",
|
||||||
|
|||||||
Binary file not shown.
@@ -19,7 +19,7 @@ const PORT = process.env.PORT || 3001;
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// In-memory runtime state (WebSocket connections only – not persisted)
|
// In-memory runtime state (WebSocket connections only - not persisted)
|
||||||
interface ConnectedUser {
|
interface ConnectedUser {
|
||||||
oderId: string;
|
oderId: string;
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
@@ -49,8 +49,10 @@ import {
|
|||||||
JoinRequest
|
JoinRequest
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw)
|
function hashPassword(pw: string) {
|
||||||
.digest('hex'); }
|
return crypto.createHash('sha256')
|
||||||
|
.update(pw)
|
||||||
|
.digest('hex'); }
|
||||||
|
|
||||||
// REST API Routes
|
// REST API Routes
|
||||||
|
|
||||||
@@ -476,6 +478,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
|||||||
serverId: viewSid,
|
serverId: viewSid,
|
||||||
users: viewUsers
|
users: viewUsers
|
||||||
}));
|
}));
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +517,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
|||||||
...message,
|
...message,
|
||||||
fromUserId: user.oderId
|
fromUserId: user.oderId
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
|
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Target user ${message.targetUserId} not found. Connected users:`,
|
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 {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
@@ -51,16 +51,16 @@ import {
|
|||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit {
|
||||||
|
store = inject(Store);
|
||||||
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
private databaseService = inject(DatabaseService);
|
private databaseService = inject(DatabaseService);
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private servers = inject(ServerDirectoryService);
|
private servers = inject(ServerDirectoryService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
private externalLinks = inject(ExternalLinkService);
|
private externalLinks = inject(ExternalLinkService);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
|
|
||||||
/** Intercept all <a> clicks and open them externally. */
|
/** Intercept all <a> clicks and open them externally. */
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
|
|||||||
@@ -7,35 +7,40 @@ import {
|
|||||||
BanEntry
|
BanEntry
|
||||||
} from '../models';
|
} 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.
|
* Database service for the Electron (desktop) runtime.
|
||||||
*
|
*
|
||||||
* All SQLite queries run in the Electron **main process**
|
* The SQLite database is managed by TypeORM in the Electron **main process**
|
||||||
* (`electron/database.js`). This service is a thin IPC client that
|
* (`electron/main.ts`). This service is a thin CQRS IPC client that dispatches
|
||||||
* delegates every operation to `window.electronAPI.db.*`.
|
* 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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ElectronDatabaseService {
|
export class ElectronDatabaseService {
|
||||||
/** Whether {@link initialize} has already been called successfully. */
|
/** Shorthand accessor for the preload-exposed CQRS API. */
|
||||||
private isInitialised = false;
|
private get api(): ElectronAPI {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
/** Shorthand accessor for the preload-exposed database API. */
|
return (window as any).electronAPI as ElectronAPI;
|
||||||
private get api() {
|
|
||||||
return (window as any).electronAPI.db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initialise the SQLite database via the main-process IPC bridge. */
|
/**
|
||||||
async initialize(): Promise<void> {
|
* No-op: the database is initialised in the main process before the
|
||||||
if (this.isInitialised)
|
* renderer window opens and requires no explicit bootstrap call here.
|
||||||
return;
|
*/
|
||||||
|
async initialize(): Promise<void> { /* no-op */ }
|
||||||
await this.api.initialize();
|
|
||||||
this.isInitialised = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist a single chat message. */
|
/** Persist a single chat message. */
|
||||||
saveMessage(message: Message): Promise<void> {
|
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).
|
* @param offset - Number of messages to skip (for pagination).
|
||||||
*/
|
*/
|
||||||
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
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. */
|
/** Permanently delete a message by ID. */
|
||||||
deleteMessage(messageId: string): Promise<void> {
|
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. */
|
/** Apply partial updates to an existing message. */
|
||||||
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
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. */
|
/** Retrieve a single message by ID, or `null` if not found. */
|
||||||
getMessageById(messageId: string): Promise<Message | null> {
|
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. */
|
/** Remove every message belonging to a room. */
|
||||||
clearRoomMessages(roomId: string): Promise<void> {
|
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> {
|
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). */
|
/** Remove a specific reaction (user + emoji + message). */
|
||||||
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
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. */
|
/** Return all reactions for a given message. */
|
||||||
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
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. */
|
/** Persist a user record. */
|
||||||
saveUser(user: User): Promise<void> {
|
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. */
|
/** Retrieve a user by ID, or `null` if not found. */
|
||||||
getUser(userId: string): Promise<User | null> {
|
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`. */
|
/** Retrieve the last-authenticated ("current") user, or `null`. */
|
||||||
getCurrentUser(): Promise<User | 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). */
|
/** Store which user ID is considered "current" (logged-in). */
|
||||||
setCurrentUserId(userId: string): Promise<void> {
|
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. */
|
/** Retrieve users associated with a room. */
|
||||||
getUsersByRoom(roomId: string): Promise<User[]> {
|
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. */
|
/** Apply partial updates to an existing user. */
|
||||||
updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
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. */
|
/** Persist a room record. */
|
||||||
saveRoom(room: Room): Promise<void> {
|
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. */
|
/** Retrieve a room by ID, or `null` if not found. */
|
||||||
getRoom(roomId: string): Promise<Room | null> {
|
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. */
|
/** Return every persisted room. */
|
||||||
getAllRooms(): Promise<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> {
|
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. */
|
/** Apply partial updates to an existing room. */
|
||||||
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
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. */
|
/** Persist a ban entry. */
|
||||||
saveBan(ban: BanEntry): Promise<void> {
|
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`. */
|
/** Remove a ban by the banned user's `oderId`. */
|
||||||
removeBan(oderId: string): Promise<void> {
|
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. */
|
/** Return active bans for a room. */
|
||||||
getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
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. */
|
/** Check whether a user is currently banned from a room. */
|
||||||
isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
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. */
|
/** Persist attachment metadata. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
saveAttachment(attachment: any): Promise<void> {
|
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. */
|
/** Return all attachment records for a message. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
getAttachmentsForMessage(messageId: string): Promise<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. */
|
/** Return every persisted attachment record. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
getAllAttachments(): Promise<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. */
|
/** Delete all attachment records for a message. */
|
||||||
deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
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. */
|
/** Wipe every table, removing all persisted data. */
|
||||||
clearAllData(): Promise<void> {
|
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 {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -67,8 +66,7 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
|||||||
* Only accessible to users with admin privileges.
|
* Only accessible to users with admin privileges.
|
||||||
*/
|
*/
|
||||||
export class AdminPanelComponent {
|
export class AdminPanelComponent {
|
||||||
private store = inject(Store);
|
store = inject(Store);
|
||||||
private webrtc = inject(WebRTCService);
|
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
@@ -95,6 +93,8 @@ export class AdminPanelComponent {
|
|||||||
adminsManageIcon = false;
|
adminsManageIcon = false;
|
||||||
moderatorsManageIcon = false;
|
moderatorsManageIcon = false;
|
||||||
|
|
||||||
|
private webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Initialize from current room
|
// Initialize from current room
|
||||||
const room = this.currentRoom();
|
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 {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
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.
|
* Login form allowing existing users to authenticate against a selected server.
|
||||||
*/
|
*/
|
||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
private auth = inject(AuthService);
|
serversSvc = inject(ServerDirectoryService);
|
||||||
private serversSvc = inject(ServerDirectoryService);
|
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
servers = this.serversSvc.servers;
|
servers = this.serversSvc.servers;
|
||||||
username = '';
|
username = '';
|
||||||
@@ -43,6 +40,10 @@ export class LoginComponent {
|
|||||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
private store = inject(Store);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
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 {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
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.
|
* Registration form allowing new users to create an account on a selected server.
|
||||||
*/
|
*/
|
||||||
export class RegisterComponent {
|
export class RegisterComponent {
|
||||||
private auth = inject(AuthService);
|
serversSvc = inject(ServerDirectoryService);
|
||||||
private serversSvc = inject(ServerDirectoryService);
|
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
servers = this.serversSvc.servers;
|
servers = this.serversSvc.servers;
|
||||||
username = '';
|
username = '';
|
||||||
@@ -44,6 +41,10 @@ export class RegisterComponent {
|
|||||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
private store = inject(Store);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
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 { Component, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
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.
|
* Compact user status bar showing the current user with login/register navigation links.
|
||||||
*/
|
*/
|
||||||
export class UserBarComponent {
|
export class UserBarComponent {
|
||||||
private store = inject(Store);
|
store = inject(Store);
|
||||||
private router = inject(Router);
|
|
||||||
user = this.store.selectSignal(selectCurrentUser);
|
user = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
/** Navigate to the specified authentication page. */
|
/** Navigate to the specified authentication page. */
|
||||||
goto(path: 'login' | 'register') {
|
goto(path: 'login' | 'register') {
|
||||||
this.router.navigate([`/${path}`]);
|
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