Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0865c2fe33 | |||
| 4a41de79d6 | |||
| 84fa45985a | |||
| 35352923a5 | |||
| b9df9c92f2 | |||
| 8674579b19 | |||
| de2d3300d4 | |||
| ae0ee8fac7 | |||
| 37cac95b38 | |||
| 314a26325f | |||
| 5d7e045764 | |||
| bbb6deb0a2 | |||
| 65b9419869 | |||
| fed270d28d | |||
| 8b6578da3c | |||
| 851d6ae759 | |||
| 1e833ec7f2 | |||
| 64e34ad586 | |||
| e3b23247a9 | |||
| 42ac712571 | |||
| b7d4bf20e3 | |||
| 727059fb52 | |||
| 83694570e3 | |||
| 109402cdd6 | |||
| eb23fd71ec | |||
| 11917f3412 | |||
| 8162e0444a | |||
| 0467a7b612 | |||
| 971a5afb8b | |||
| fe9c1dd1c0 | |||
| 429bb9d8ff | |||
| b5d676fb78 | |||
| aa595c45d8 | |||
| 1c7e535057 | |||
| 8f960be1e9 | |||
| 9a173792a4 | |||
| cb2c0495b9 |
@@ -67,8 +67,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
run: |
|
||||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
|
||||||
|
cd toju-app
|
||||||
npx ng build --configuration production --base-href='./'
|
npx ng build --configuration production --base-href='./'
|
||||||
|
cd ..
|
||||||
npx --package typescript tsc -p tsconfig.electron.json
|
npx --package typescript tsc -p tsconfig.electron.json
|
||||||
cd server
|
cd server
|
||||||
node ../tools/sync-server-build-version.js
|
node ../tools/sync-server-build-version.js
|
||||||
@@ -120,8 +122,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
run: |
|
||||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
|
||||||
|
Push-Location "toju-app"
|
||||||
npx ng build --configuration production --base-href='./'
|
npx ng build --configuration production --base-href='./'
|
||||||
|
Pop-Location
|
||||||
npx --package typescript tsc -p tsconfig.electron.json
|
npx --package typescript tsc -p tsconfig.electron.json
|
||||||
Push-Location server
|
Push-Location server
|
||||||
node ../tools/sync-server-build-version.js
|
node ../tools/sync-server-build-version.js
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,7 +6,9 @@
|
|||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
|
*.sqlite
|
||||||
|
*/architecture.md
|
||||||
|
/docs
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
@@ -51,3 +53,6 @@ Thumbs.db
|
|||||||
.certs/
|
.certs/
|
||||||
/server/data/variables.json
|
/server/data/variables.json
|
||||||
dist-server/*
|
dist-server/*
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
doc/**
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -1,10 +1,14 @@
|
|||||||
|
<img src="./images/icon.png" width="100" height="100">
|
||||||
|
|
||||||
|
|
||||||
# Toju / Zoracord
|
# Toju / Zoracord
|
||||||
|
|
||||||
Desktop chat app with three parts:
|
Desktop chat app with four parts:
|
||||||
|
|
||||||
- `src/` Angular client
|
- `src/` Angular client
|
||||||
- `electron/` desktop shell, IPC, and local database
|
- `electron/` desktop shell, IPC, and local database
|
||||||
- `server/` directory server, join request API, and websocket events
|
- `server/` directory server, join request API, and websocket events
|
||||||
|
- `website/` Toju website served at toju.app
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -52,3 +56,64 @@ Inside `server/`:
|
|||||||
- `npm run dev` starts the server with reload
|
- `npm run dev` starts the server with reload
|
||||||
- `npm run build` compiles to `dist/`
|
- `npm run build` compiles to `dist/`
|
||||||
- `npm run start` runs the compiled server
|
- `npm run start` runs the compiled server
|
||||||
|
|
||||||
|
# Images
|
||||||
|
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
|
||||||
|
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
|
||||||
|
|
||||||
|
## Main Toju app Structure
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `src/app/` | Main application root |
|
||||||
|
| `src/app/core/` | Core utilities, services, models |
|
||||||
|
| `src/app/domains/` | Domain-driven modules |
|
||||||
|
| `src/app/features/` | UI feature modules |
|
||||||
|
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
|
||||||
|
| `src/app/shared/` | Shared UI components |
|
||||||
|
| `src/app/shared-kernel/` | Shared domain contracts & models |
|
||||||
|
| `src/app/store/` | Global state management |
|
||||||
|
| `src/assets/` | Static assets |
|
||||||
|
| `src/environments/` | Environment configs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Domains
|
||||||
|
|
||||||
|
| Path | Link |
|
||||||
|
|------|------|
|
||||||
|
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
|
||||||
|
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
|
||||||
|
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
|
||||||
|
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
|
||||||
|
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
|
||||||
|
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
|
||||||
|
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
|
||||||
|
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
| Path | Link |
|
||||||
|
|------|------|
|
||||||
|
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
|
||||||
|
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Shared Kernel
|
||||||
|
|
||||||
|
| Path | Link |
|
||||||
|
|------|------|
|
||||||
|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
|
||||||
|
| File | Link |
|
||||||
|
|------|------|
|
||||||
|
| Main | [main.ts](src/main.ts) |
|
||||||
|
| Index HTML | [index.html](src/index.html) |
|
||||||
|
| App Root | [app/app.ts](src/app/app.ts) |
|
||||||
|
|||||||
4
dev.sh
4
dev.sh
@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
|
|||||||
"$DIR/generate-cert.sh"
|
"$DIR/generate-cert.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
|
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
|
||||||
WAIT_URL="https://localhost:4200"
|
WAIT_URL="https://localhost:4200"
|
||||||
HEALTH_URL="https://localhost:3001/api/health"
|
HEALTH_URL="https://localhost:3001/api/health"
|
||||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
else
|
else
|
||||||
NG_SERVE="ng serve --host=0.0.0.0"
|
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
||||||
WAIT_URL="http://localhost:4200"
|
WAIT_URL="http://localhost:4200"
|
||||||
HEALTH_URL="http://localhost:3001/api/health"
|
HEALTH_URL="http://localhost:3001/api/health"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import AutoLaunch from 'auto-launch';
|
import AutoLaunch from 'auto-launch';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
import { readDesktopSettings } from '../desktop-settings';
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
|
||||||
let autoLauncher: AutoLaunch | null = null;
|
let autoLauncher: AutoLaunch | null = null;
|
||||||
|
let autoLaunchPath = '';
|
||||||
|
|
||||||
|
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
|
||||||
|
|
||||||
function resolveLaunchPath(): string {
|
function resolveLaunchPath(): string {
|
||||||
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
||||||
@@ -13,15 +18,77 @@ function resolveLaunchPath(): string {
|
|||||||
return appImagePath || process.execPath;
|
return appImagePath || process.execPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeDesktopEntryExecArgument(argument: string): string {
|
||||||
|
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
|
||||||
|
|
||||||
|
return /[\s"]/u.test(argument)
|
||||||
|
? `"${escapedArgument}"`
|
||||||
|
: escapedArgument;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinuxAutoStartDesktopEntryPath(launchPath: string): string {
|
||||||
|
return path.join(app.getPath('home'), '.config', 'autostart', `${path.basename(launchPath)}.desktop`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinuxAutoStartExecLine(launchPath: string): string {
|
||||||
|
return `Exec=${[escapeDesktopEntryExecArgument(launchPath), ...LINUX_AUTO_START_ARGUMENTS].join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
|
||||||
|
const appName = path.basename(launchPath);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'[Desktop Entry]',
|
||||||
|
'Type=Application',
|
||||||
|
'Version=1.0',
|
||||||
|
`Name=${appName}`,
|
||||||
|
`Comment=${appName}startup script`,
|
||||||
|
buildLinuxAutoStartExecLine(launchPath),
|
||||||
|
'StartupNotify=false',
|
||||||
|
'Terminal=false'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promise<void> {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopEntryPath = getLinuxAutoStartDesktopEntryPath(launchPath);
|
||||||
|
const execLine = buildLinuxAutoStartExecLine(launchPath);
|
||||||
|
|
||||||
|
let currentDesktopEntry = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentDesktopEntry = await fsp.readFile(desktopEntryPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Create the desktop entry if auto-launch did not leave one behind.
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDesktopEntry = currentDesktopEntry
|
||||||
|
? /^Exec=.*$/m.test(currentDesktopEntry)
|
||||||
|
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
|
||||||
|
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
|
||||||
|
: buildLinuxAutoStartDesktopEntry(launchPath);
|
||||||
|
|
||||||
|
if (nextDesktopEntry === currentDesktopEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(desktopEntryPath), { recursive: true });
|
||||||
|
await fsp.writeFile(desktopEntryPath, nextDesktopEntry, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
function getAutoLauncher(): AutoLaunch | null {
|
function getAutoLauncher(): AutoLaunch | null {
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!autoLauncher) {
|
if (!autoLauncher) {
|
||||||
|
autoLaunchPath = resolveLaunchPath();
|
||||||
autoLauncher = new AutoLaunch({
|
autoLauncher = new AutoLaunch({
|
||||||
name: app.getName(),
|
name: app.getName(),
|
||||||
path: resolveLaunchPath()
|
path: autoLaunchPath
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +104,16 @@ async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
|||||||
|
|
||||||
const currentlyEnabled = await launcher.isEnabled();
|
const currentlyEnabled = await launcher.isEnabled();
|
||||||
|
|
||||||
if (currentlyEnabled === enabled) {
|
if (!enabled && currentlyEnabled === enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await launcher.enable();
|
if (!currentlyEnabled) {
|
||||||
|
await launcher.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
getDataSource
|
getDataSource
|
||||||
} from '../db/database';
|
} from '../db/database';
|
||||||
import { createWindow, getDockIconPath } from '../window/create-window';
|
import {
|
||||||
|
createWindow,
|
||||||
|
getDockIconPath,
|
||||||
|
getMainWindow,
|
||||||
|
prepareWindowForAppQuit,
|
||||||
|
showMainWindow
|
||||||
|
} from '../window/create-window';
|
||||||
import {
|
import {
|
||||||
setupCqrsHandlers,
|
setupCqrsHandlers,
|
||||||
setupSystemHandlers,
|
setupSystemHandlers,
|
||||||
@@ -30,8 +36,13 @@ export function registerAppLifecycle(): void {
|
|||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
if (getMainWindow()) {
|
||||||
|
void showMainWindow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0)
|
if (BrowserWindow.getAllWindows().length === 0)
|
||||||
createWindow();
|
void createWindow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +52,8 @@ export function registerAppLifecycle(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', async (event) => {
|
app.on('before-quit', async (event) => {
|
||||||
|
prepareWindowForAppQuit();
|
||||||
|
|
||||||
if (getDataSource()?.isInitialized) {
|
if (getDataSource()?.isInitialized) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shutdownDesktopUpdater();
|
shutdownDesktopUpdater();
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -13,6 +18,11 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
|||||||
await dataSource.getRepository(MessageEntity).clear();
|
await dataSource.getRepository(MessageEntity).clear();
|
||||||
await dataSource.getRepository(UserEntity).clear();
|
await dataSource.getRepository(UserEntity).clear();
|
||||||
await dataSource.getRepository(RoomEntity).clear();
|
await dataSource.getRepository(RoomEntity).clear();
|
||||||
|
await dataSource.getRepository(RoomChannelEntity).clear();
|
||||||
|
await dataSource.getRepository(RoomMemberEntity).clear();
|
||||||
|
await dataSource.getRepository(RoomRoleEntity).clear();
|
||||||
|
await dataSource.getRepository(RoomUserRoleEntity).clear();
|
||||||
|
await dataSource.getRepository(RoomChannelPermissionEntity).clear();
|
||||||
await dataSource.getRepository(ReactionEntity).clear();
|
await dataSource.getRepository(ReactionEntity).clear();
|
||||||
await dataSource.getRepository(BanEntity).clear();
|
await dataSource.getRepository(BanEntity).clear();
|
||||||
await dataSource.getRepository(AttachmentEntity).clear();
|
await dataSource.getRepository(AttachmentEntity).clear();
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity, MessageEntity } from '../../../entities';
|
import {
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
MessageEntity
|
||||||
|
} from '../../../entities';
|
||||||
import { DeleteRoomCommand } from '../../types';
|
import { DeleteRoomCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { roomId } = command.payload;
|
const { roomId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
|
await dataSource.transaction(async (manager) => {
|
||||||
await dataSource.getRepository(MessageEntity).delete({ roomId });
|
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
|
||||||
|
await manager.getRepository(RoomChannelEntity).delete({ roomId });
|
||||||
|
await manager.getRepository(RoomMemberEntity).delete({ roomId });
|
||||||
|
await manager.getRepository(RoomRoleEntity).delete({ roomId });
|
||||||
|
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
|
||||||
|
await manager.getRepository(RoomEntity).delete({ id: roomId });
|
||||||
|
await manager.getRepository(MessageEntity).delete({ roomId });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
|
import { replaceMessageReactions } from '../../relations';
|
||||||
import { SaveMessageCommand } from '../../types';
|
import { SaveMessageCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
|
||||||
const { message } = command.payload;
|
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);
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(MessageEntity);
|
||||||
|
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,
|
||||||
|
isDeleted: message.isDeleted ? 1 : 0,
|
||||||
|
replyToId: message.replyToId ?? null,
|
||||||
|
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,55 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
|
import { replaceRoomRelations } from '../../relations';
|
||||||
import { SaveRoomCommand } from '../../types';
|
import { SaveRoomCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
|
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
|
||||||
const { room } = command.payload;
|
return room.slowModeInterval;
|
||||||
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,
|
|
||||||
hasPassword: room.hasPassword ? 1 : 0,
|
|
||||||
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,
|
|
||||||
members: room.members != null ? JSON.stringify(room.members) : null,
|
|
||||||
sourceId: room.sourceId ?? null,
|
|
||||||
sourceName: room.sourceName ?? null,
|
|
||||||
sourceUrl: room.sourceUrl ?? null
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(entity);
|
const permissions = room.permissions && typeof room.permissions === 'object'
|
||||||
|
? room.permissions as { slowModeInterval?: unknown }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
|
||||||
|
? permissions.slowModeInterval
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
|
||||||
|
const { room } = command.payload;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(RoomEntity);
|
||||||
|
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,
|
||||||
|
hasPassword: room.hasPassword ? 1 : 0,
|
||||||
|
isPrivate: room.isPrivate ? 1 : 0,
|
||||||
|
createdAt: room.createdAt,
|
||||||
|
userCount: room.userCount ?? 0,
|
||||||
|
maxUsers: room.maxUsers ?? null,
|
||||||
|
icon: room.icon ?? null,
|
||||||
|
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||||
|
slowModeInterval: extractSlowModeInterval(room),
|
||||||
|
sourceId: room.sourceId ?? null,
|
||||||
|
sourceName: room.sourceName ?? null,
|
||||||
|
sourceUrl: room.sourceUrl ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
await replaceRoomRelations(manager, room.id, {
|
||||||
|
channels: room.channels ?? [],
|
||||||
|
members: room.members ?? [],
|
||||||
|
roles: room.roles ?? [],
|
||||||
|
roleAssignments: room.roleAssignments ?? [],
|
||||||
|
channelPermissions: room.channelPermissions ?? [],
|
||||||
|
permissions: room.permissions
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,52 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
|
import { replaceMessageReactions } from '../../relations';
|
||||||
import { UpdateMessageCommand } from '../../types';
|
import { UpdateMessageCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
|
||||||
const { messageId, updates } = command.payload;
|
const { messageId, updates } = command.payload;
|
||||||
const existing = await repo.findOne({ where: { id: messageId } });
|
|
||||||
|
|
||||||
if (!existing)
|
await dataSource.transaction(async (manager) => {
|
||||||
return;
|
const repo = manager.getRepository(MessageEntity);
|
||||||
|
const existing = await repo.findOne({ where: { id: messageId } });
|
||||||
|
|
||||||
if (updates.channelId !== undefined)
|
if (!existing)
|
||||||
existing.channelId = updates.channelId ?? null;
|
return;
|
||||||
|
|
||||||
if (updates.senderId !== undefined)
|
const directFields = [
|
||||||
existing.senderId = updates.senderId;
|
'senderId',
|
||||||
|
'senderName',
|
||||||
|
'content',
|
||||||
|
'timestamp'
|
||||||
|
] as const;
|
||||||
|
const entity = existing as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
if (updates.senderName !== undefined)
|
for (const field of directFields) {
|
||||||
existing.senderName = updates.senderName;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field];
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.content !== undefined)
|
const nullableFields = [
|
||||||
existing.content = updates.content;
|
'channelId',
|
||||||
|
'editedAt',
|
||||||
|
'replyToId'
|
||||||
|
] as const;
|
||||||
|
|
||||||
if (updates.timestamp !== undefined)
|
for (const field of nullableFields) {
|
||||||
existing.timestamp = updates.timestamp;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.editedAt !== undefined)
|
if (updates.isDeleted !== undefined)
|
||||||
existing.editedAt = updates.editedAt ?? null;
|
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||||
|
|
||||||
if (updates.reactions !== undefined)
|
if (updates.linkMetadata !== undefined)
|
||||||
existing.reactions = JSON.stringify(updates.reactions ?? []);
|
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||||
|
|
||||||
if (updates.isDeleted !== undefined)
|
await repo.save(existing);
|
||||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
|
||||||
|
|
||||||
if (updates.replyToId !== undefined)
|
if (updates.reactions !== undefined) {
|
||||||
existing.replyToId = updates.replyToId ?? null;
|
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
|
||||||
|
}
|
||||||
await repo.save(existing);
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,68 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
|
import { replaceRoomRelations } from '../../relations';
|
||||||
import { UpdateRoomCommand } from '../../types';
|
import { UpdateRoomCommand } from '../../types';
|
||||||
import {
|
import {
|
||||||
applyUpdates,
|
applyUpdates,
|
||||||
boolToInt,
|
boolToInt,
|
||||||
jsonOrNull,
|
|
||||||
TransformMap
|
TransformMap
|
||||||
} from './utils/applyUpdates';
|
} from './utils/applyUpdates';
|
||||||
|
|
||||||
const ROOM_TRANSFORMS: TransformMap = {
|
const ROOM_TRANSFORMS: TransformMap = {
|
||||||
hasPassword: boolToInt,
|
hasPassword: boolToInt,
|
||||||
isPrivate: boolToInt,
|
isPrivate: boolToInt,
|
||||||
userCount: (val) => (val ?? 0),
|
userCount: (val) => (val ?? 0)
|
||||||
permissions: jsonOrNull,
|
|
||||||
channels: jsonOrNull,
|
|
||||||
members: jsonOrNull
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
|
||||||
const { roomId, updates } = command.payload;
|
return updates.slowModeInterval;
|
||||||
const existing = await repo.findOne({ where: { id: roomId } });
|
}
|
||||||
|
|
||||||
if (!existing)
|
const permissions = updates.permissions && typeof updates.permissions === 'object'
|
||||||
return;
|
? updates.permissions as { slowModeInterval?: unknown }
|
||||||
|
: null;
|
||||||
|
|
||||||
applyUpdates(existing, updates, ROOM_TRANSFORMS);
|
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
|
||||||
await repo.save(existing);
|
? permissions.slowModeInterval
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
||||||
|
const { roomId, updates } = command.payload;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(RoomEntity);
|
||||||
|
const existing = await repo.findOne({ where: { id: roomId } });
|
||||||
|
|
||||||
|
if (!existing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
channels,
|
||||||
|
members,
|
||||||
|
roles,
|
||||||
|
roleAssignments,
|
||||||
|
channelPermissions,
|
||||||
|
permissions: rawPermissions,
|
||||||
|
...entityUpdates
|
||||||
|
} = updates;
|
||||||
|
const slowModeInterval = extractSlowModeInterval(updates);
|
||||||
|
|
||||||
|
if (slowModeInterval !== undefined) {
|
||||||
|
entityUpdates.slowModeInterval = slowModeInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
|
||||||
|
await repo.save(existing);
|
||||||
|
await replaceRoomRelations(manager, roomId, {
|
||||||
|
channels,
|
||||||
|
members,
|
||||||
|
roles,
|
||||||
|
roleAssignments,
|
||||||
|
channelPermissions,
|
||||||
|
permissions: rawPermissions
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ import { RoomEntity } from '../entities/RoomEntity';
|
|||||||
import { ReactionEntity } from '../entities/ReactionEntity';
|
import { ReactionEntity } from '../entities/ReactionEntity';
|
||||||
import { BanEntity } from '../entities/BanEntity';
|
import { BanEntity } from '../entities/BanEntity';
|
||||||
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
||||||
|
import { ReactionPayload } from './types';
|
||||||
|
import {
|
||||||
|
relationRecordToRoomPayload,
|
||||||
|
RoomChannelPermissionRecord,
|
||||||
|
RoomChannelRecord,
|
||||||
|
RoomMemberRecord,
|
||||||
|
RoomRoleAssignmentRecord,
|
||||||
|
RoomRoleRecord
|
||||||
|
} from './relations';
|
||||||
|
|
||||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||||
|
|
||||||
export function rowToMessage(row: MessageEntity) {
|
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
|
||||||
const isDeleted = !!row.isDeleted;
|
const isDeleted = !!row.isDeleted;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -24,9 +33,10 @@ export function rowToMessage(row: MessageEntity) {
|
|||||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
editedAt: row.editedAt ?? undefined,
|
editedAt: row.editedAt ?? undefined,
|
||||||
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
|
reactions: isDeleted ? [] : reactions,
|
||||||
isDeleted,
|
isDeleted,
|
||||||
replyToId: row.replyToId ?? undefined
|
replyToId: row.replyToId ?? undefined,
|
||||||
|
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +59,30 @@ export function rowToUser(row: UserEntity) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToRoom(row: RoomEntity) {
|
export function rowToRoom(
|
||||||
|
row: RoomEntity,
|
||||||
|
relations: {
|
||||||
|
channels?: RoomChannelRecord[];
|
||||||
|
members?: RoomMemberRecord[];
|
||||||
|
roles?: RoomRoleRecord[];
|
||||||
|
roleAssignments?: RoomRoleAssignmentRecord[];
|
||||||
|
channelPermissions?: RoomChannelPermissionRecord[];
|
||||||
|
} = {
|
||||||
|
channels: [],
|
||||||
|
members: [],
|
||||||
|
roles: [],
|
||||||
|
roleAssignments: [],
|
||||||
|
channelPermissions: []
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const relationPayload = relationRecordToRoomPayload({ slowModeInterval: row.slowModeInterval }, {
|
||||||
|
channels: relations.channels ?? [],
|
||||||
|
members: relations.members ?? [],
|
||||||
|
roles: relations.roles ?? [],
|
||||||
|
roleAssignments: relations.roleAssignments ?? [],
|
||||||
|
channelPermissions: relations.channelPermissions ?? []
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -64,9 +97,13 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
maxUsers: row.maxUsers ?? undefined,
|
maxUsers: row.maxUsers ?? undefined,
|
||||||
icon: row.icon ?? undefined,
|
icon: row.icon ?? undefined,
|
||||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
slowModeInterval: row.slowModeInterval,
|
||||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
permissions: relationPayload.permissions,
|
||||||
members: row.members ? JSON.parse(row.members) : undefined,
|
channels: relationPayload.channels,
|
||||||
|
members: relationPayload.members,
|
||||||
|
roles: relationPayload.roles,
|
||||||
|
roleAssignments: relationPayload.roleAssignments,
|
||||||
|
channelPermissions: relationPayload.channelPermissions,
|
||||||
sourceId: row.sourceId ?? undefined,
|
sourceId: row.sourceId ?? undefined,
|
||||||
sourceName: row.sourceName ?? undefined,
|
sourceName: row.sourceName ?? undefined,
|
||||||
sourceUrl: row.sourceUrl ?? undefined
|
sourceUrl: row.sourceUrl ?? undefined
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
import { rowToRoom } from '../../mappers';
|
import { rowToRoom } from '../../mappers';
|
||||||
|
import { loadRoomRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
const repo = dataSource.getRepository(RoomEntity);
|
||||||
const rows = await repo.find();
|
const rows = await repo.find();
|
||||||
|
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
|
||||||
|
|
||||||
return rows.map(rowToRoom);
|
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
|||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
import { GetMessageByIdQuery } from '../../types';
|
import { GetMessageByIdQuery } from '../../types';
|
||||||
import { rowToMessage } from '../../mappers';
|
import { rowToMessage } from '../../mappers';
|
||||||
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
const repo = dataSource.getRepository(MessageEntity);
|
||||||
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
||||||
|
|
||||||
return row ? rowToMessage(row) : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
|
||||||
|
|
||||||
|
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
|
|||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
import { GetMessagesQuery } from '../../types';
|
import { GetMessagesQuery } from '../../types';
|
||||||
import { rowToMessage } from '../../mappers';
|
import { rowToMessage } from '../../mappers';
|
||||||
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
const repo = dataSource.getRepository(MessageEntity);
|
||||||
@@ -12,6 +13,7 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
|
|||||||
take: limit,
|
take: limit,
|
||||||
skip: offset
|
skip: offset
|
||||||
});
|
});
|
||||||
|
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||||
|
|
||||||
return rows.map(rowToMessage);
|
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||||
}
|
}
|
||||||
|
|||||||
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { DataSource, MoreThan } from 'typeorm';
|
||||||
|
import { MessageEntity } from '../../../entities';
|
||||||
|
import { GetMessagesSinceQuery } from '../../types';
|
||||||
|
import { rowToMessage } from '../../mappers';
|
||||||
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
|
||||||
|
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
|
||||||
|
const repo = dataSource.getRepository(MessageEntity);
|
||||||
|
const { roomId, sinceTimestamp } = query.payload;
|
||||||
|
const rows = await repo.find({
|
||||||
|
where: {
|
||||||
|
roomId,
|
||||||
|
timestamp: MoreThan(sinceTimestamp)
|
||||||
|
},
|
||||||
|
order: { timestamp: 'ASC' }
|
||||||
|
});
|
||||||
|
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||||
|
|
||||||
|
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||||
|
}
|
||||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
|||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
import { GetRoomQuery } from '../../types';
|
import { GetRoomQuery } from '../../types';
|
||||||
import { rowToRoom } from '../../mappers';
|
import { rowToRoom } from '../../mappers';
|
||||||
|
import { loadRoomRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
const repo = dataSource.getRepository(RoomEntity);
|
||||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
||||||
|
|
||||||
return row ? rowToRoom(row) : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
|
||||||
|
|
||||||
|
return rowToRoom(row, relationsByRoomId.get(row.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
QueryTypeKey,
|
QueryTypeKey,
|
||||||
Query,
|
Query,
|
||||||
GetMessagesQuery,
|
GetMessagesQuery,
|
||||||
|
GetMessagesSinceQuery,
|
||||||
GetMessageByIdQuery,
|
GetMessageByIdQuery,
|
||||||
GetReactionsForMessageQuery,
|
GetReactionsForMessageQuery,
|
||||||
GetUserQuery,
|
GetUserQuery,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
GetAttachmentsForMessageQuery
|
GetAttachmentsForMessageQuery
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { handleGetMessages } from './handlers/getMessages';
|
import { handleGetMessages } from './handlers/getMessages';
|
||||||
|
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||||
import { handleGetMessageById } from './handlers/getMessageById';
|
import { handleGetMessageById } from './handlers/getMessageById';
|
||||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||||
import { handleGetUser } from './handlers/getUser';
|
import { handleGetUser } from './handlers/getUser';
|
||||||
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
|||||||
|
|
||||||
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
||||||
|
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
|
||||||
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
|
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
|
||||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||||
|
|||||||
1002
electron/cqrs/relations.ts
Normal file
1002
electron/cqrs/relations.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
|||||||
|
|
||||||
export const QueryType = {
|
export const QueryType = {
|
||||||
GetMessages: 'get-messages',
|
GetMessages: 'get-messages',
|
||||||
|
GetMessagesSince: 'get-messages-since',
|
||||||
GetMessageById: 'get-message-by-id',
|
GetMessageById: 'get-message-by-id',
|
||||||
GetReactionsForMessage: 'get-reactions-for-message',
|
GetReactionsForMessage: 'get-reactions-for-message',
|
||||||
GetUser: 'get-user',
|
GetUser: 'get-user',
|
||||||
@@ -49,6 +50,7 @@ export interface MessagePayload {
|
|||||||
reactions?: ReactionPayload[];
|
reactions?: ReactionPayload[];
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactionPayload {
|
export interface ReactionPayload {
|
||||||
@@ -60,6 +62,44 @@ export interface ReactionPayload {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
export type RoomPermissionKeyPayload =
|
||||||
|
| 'manageServer'
|
||||||
|
| 'manageRoles'
|
||||||
|
| 'manageChannels'
|
||||||
|
| 'manageIcon'
|
||||||
|
| 'kickMembers'
|
||||||
|
| 'banMembers'
|
||||||
|
| 'manageBans'
|
||||||
|
| 'deleteMessages'
|
||||||
|
| 'joinVoice'
|
||||||
|
| 'shareScreen'
|
||||||
|
| 'uploadFiles';
|
||||||
|
|
||||||
|
export interface AccessRolePayload {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
position: number;
|
||||||
|
isSystem?: boolean;
|
||||||
|
permissions?: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleAssignmentPayload {
|
||||||
|
userId: string;
|
||||||
|
oderId?: string;
|
||||||
|
roleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelPermissionPayload {
|
||||||
|
channelId: string;
|
||||||
|
targetType: 'role' | 'user';
|
||||||
|
targetId: string;
|
||||||
|
permission: RoomPermissionKeyPayload;
|
||||||
|
value: PermissionStatePayload;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserPayload {
|
export interface UserPayload {
|
||||||
id: string;
|
id: string;
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
@@ -91,9 +131,13 @@ export interface RoomPayload {
|
|||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconUpdatedAt?: number;
|
iconUpdatedAt?: number;
|
||||||
|
slowModeInterval?: number;
|
||||||
permissions?: unknown;
|
permissions?: unknown;
|
||||||
channels?: unknown[];
|
channels?: unknown[];
|
||||||
members?: unknown[];
|
members?: unknown[];
|
||||||
|
roles?: AccessRolePayload[];
|
||||||
|
roleAssignments?: RoleAssignmentPayload[];
|
||||||
|
channelPermissions?: ChannelPermissionPayload[];
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
sourceUrl?: string;
|
sourceUrl?: string;
|
||||||
@@ -160,6 +204,7 @@ export type Command =
|
|||||||
| ClearAllDataCommand;
|
| ClearAllDataCommand;
|
||||||
|
|
||||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
||||||
|
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
|
||||||
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
||||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; 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 GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
|
||||||
@@ -174,6 +219,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
|
|||||||
|
|
||||||
export type Query =
|
export type Query =
|
||||||
| GetMessagesQuery
|
| GetMessagesQuery
|
||||||
|
| GetMessagesSinceQuery
|
||||||
| GetMessageByIdQuery
|
| GetMessageByIdQuery
|
||||||
| GetReactionsForMessageQuery
|
| GetReactionsForMessageQuery
|
||||||
| GetUserQuery
|
| GetUserQuery
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -38,6 +43,11 @@ export const AppDataSource = new DataSource({
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -40,6 +45,11 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
|||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -21,6 +22,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
|
closeToTray: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -86,6 +88,9 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
autoStart: typeof parsed.autoStart === 'boolean'
|
autoStart: typeof parsed.autoStart === 'boolean'
|
||||||
? parsed.autoStart
|
? parsed.autoStart
|
||||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
|
closeToTray: typeof parsed.closeToTray === 'boolean'
|
||||||
|
? parsed.closeToTray
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||||
? parsed.vaapiVideoEncode
|
? parsed.vaapiVideoEncode
|
||||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||||
@@ -110,6 +115,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||||
? mergedSettings.autoStart
|
? mergedSettings.autoStart
|
||||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
|
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
|
||||||
|
? mergedSettings.closeToTray
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ export class MessageEntity {
|
|||||||
@Column('integer', { nullable: true })
|
@Column('integer', { nullable: true })
|
||||||
editedAt!: number | null;
|
editedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { default: '[]' })
|
|
||||||
reactions!: string;
|
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isDeleted!: number;
|
isDeleted!: number;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
replyToId!: string | null;
|
replyToId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
linkMetadata!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
23
electron/entities/RoomChannelEntity.ts
Normal file
23
electron/entities/RoomChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('room_channels')
|
||||||
|
export class RoomChannelEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roomId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
channelId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
type!: 'text' | 'voice';
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
position!: number;
|
||||||
|
}
|
||||||
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('room_channel_permissions')
|
||||||
|
export class RoomChannelPermissionEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roomId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
channelId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
targetType!: 'role' | 'user';
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
targetId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
permission!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
value!: 'allow' | 'deny' | 'inherit';
|
||||||
|
}
|
||||||
@@ -45,14 +45,8 @@ export class RoomEntity {
|
|||||||
@Column('integer', { nullable: true })
|
@Column('integer', { nullable: true })
|
||||||
iconUpdatedAt!: number | null;
|
iconUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('integer', { default: 0 })
|
||||||
permissions!: string | null;
|
slowModeInterval!: number;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
channels!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
members!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
sourceId!: string | null;
|
sourceId!: string | null;
|
||||||
|
|||||||
38
electron/entities/RoomMemberEntity.ts
Normal file
38
electron/entities/RoomMemberEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('room_members')
|
||||||
|
export class RoomMemberEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roomId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
memberKey!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
oderId!: string | null;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
role!: 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
joinedAt!: number;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
lastSeenAt!: number;
|
||||||
|
}
|
||||||
59
electron/entities/RoomRoleEntity.ts
Normal file
59
electron/entities/RoomRoleEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('room_roles')
|
||||||
|
export class RoomRoleEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roomId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roleId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
color!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
position!: number;
|
||||||
|
|
||||||
|
@Column('integer', { default: 0 })
|
||||||
|
isSystem!: number;
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageServer!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageRoles!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageChannels!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageIcon!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
kickMembers!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
banMembers!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageBans!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
deleteMessages!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
joinVoice!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
shareScreen!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
uploadFiles!: 'allow' | 'deny' | 'inherit';
|
||||||
|
}
|
||||||
23
electron/entities/RoomUserRoleEntity.ts
Normal file
23
electron/entities/RoomUserRoleEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('room_user_roles')
|
||||||
|
export class RoomUserRoleEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roomId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
userKey!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roleId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
oderId!: string | null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
export { MessageEntity } from './MessageEntity';
|
export { MessageEntity } from './MessageEntity';
|
||||||
export { UserEntity } from './UserEntity';
|
export { UserEntity } from './UserEntity';
|
||||||
export { RoomEntity } from './RoomEntity';
|
export { RoomEntity } from './RoomEntity';
|
||||||
|
export { RoomChannelEntity } from './RoomChannelEntity';
|
||||||
|
export { RoomMemberEntity } from './RoomMemberEntity';
|
||||||
|
export { RoomRoleEntity } from './RoomRoleEntity';
|
||||||
|
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
|
||||||
|
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
|
||||||
export { ReactionEntity } from './ReactionEntity';
|
export { ReactionEntity } from './ReactionEntity';
|
||||||
export { BanEntity } from './BanEntity';
|
export { BanEntity } from './BanEntity';
|
||||||
export { AttachmentEntity } from './AttachmentEntity';
|
export { AttachmentEntity } from './AttachmentEntity';
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
|
nativeImage,
|
||||||
|
net,
|
||||||
|
Notification,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -28,10 +31,23 @@ import {
|
|||||||
getDesktopUpdateState,
|
getDesktopUpdateState,
|
||||||
handleDesktopSettingsChanged,
|
handleDesktopSettingsChanged,
|
||||||
restartToApplyUpdate,
|
restartToApplyUpdate,
|
||||||
|
readDesktopUpdateServerHealth,
|
||||||
type DesktopUpdateServerContext
|
type DesktopUpdateServerContext
|
||||||
} from '../update/desktop-updater';
|
} from '../update/desktop-updater';
|
||||||
import { consumePendingDeepLink } from '../app/deep-links';
|
import { consumePendingDeepLink } from '../app/deep-links';
|
||||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||||
|
import {
|
||||||
|
getMainWindow,
|
||||||
|
getWindowIconPath,
|
||||||
|
updateCloseToTraySetting
|
||||||
|
} from '../window/create-window';
|
||||||
|
import {
|
||||||
|
deleteSavedTheme,
|
||||||
|
getSavedThemesPath,
|
||||||
|
listSavedThemes,
|
||||||
|
readSavedTheme,
|
||||||
|
writeSavedTheme
|
||||||
|
} from '../theme-library';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -85,6 +101,12 @@ interface ClipboardFilePayload {
|
|||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DesktopNotificationPayload {
|
||||||
|
body: string;
|
||||||
|
requestAttention?: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLinuxDisplayServer(): string {
|
function resolveLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -312,11 +334,91 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||||
|
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||||
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||||
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||||
|
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||||
|
return await writeSavedTheme(fileName, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
|
||||||
|
return await deleteSavedTheme(fileName);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||||
|
|
||||||
|
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
|
||||||
|
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
|
||||||
|
const body = typeof payload?.body === 'string' ? payload.body : '';
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
const suppressSystemNotification = mainWindow?.isVisible() === true
|
||||||
|
&& !mainWindow.isMinimized()
|
||||||
|
&& mainWindow.isMaximized();
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suppressSystemNotification && Notification.isSupported()) {
|
||||||
|
try {
|
||||||
|
const notification = new Notification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
icon: getWindowIconPath(),
|
||||||
|
silent: true
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.on('click', () => {
|
||||||
|
if (!mainWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainWindow.isVisible()) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.show();
|
||||||
|
} catch {
|
||||||
|
// Ignore notification center failures and still attempt taskbar attention.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
|
||||||
|
mainWindow.flashFrame(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('request-window-attention', () => {
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.flashFrame(true);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('clear-window-attention', () => {
|
||||||
|
getMainWindow()?.flashFrame(false);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
|
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
|
||||||
|
|
||||||
|
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
|
||||||
|
return await readDesktopUpdateServerHealth(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
||||||
return await configureDesktopUpdaterContext(context);
|
return await configureDesktopUpdaterContext(context);
|
||||||
});
|
});
|
||||||
@@ -331,6 +433,7 @@ export function setupSystemHandlers(): void {
|
|||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
|
updateCloseToTraySetting(snapshot.closeToTray);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
@@ -402,4 +505,34 @@ export function setupSystemHandlers(): void {
|
|||||||
await fsp.mkdir(dirPath, { recursive: true });
|
await fsp.mkdir(dirPath, { recursive: true });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
|
||||||
|
if (typeof srcURL !== 'string' || !srcURL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const request = net.request(srcURL);
|
||||||
|
|
||||||
|
request.on('response', (response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
const image = nativeImage.createFromBuffer(Buffer.concat(chunks));
|
||||||
|
|
||||||
|
if (!image.isEmpty()) {
|
||||||
|
clipboard.writeImage(image);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
response.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', () => resolve(false));
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
type LegacyMessageRow = {
|
||||||
|
id: string;
|
||||||
|
reactions: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LegacyRoomRow = {
|
||||||
|
id: string;
|
||||||
|
channels: string | null;
|
||||||
|
members: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChannelType = 'text' | 'voice';
|
||||||
|
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
|
||||||
|
type LegacyReaction = {
|
||||||
|
id?: unknown;
|
||||||
|
oderId?: unknown;
|
||||||
|
userId?: unknown;
|
||||||
|
emoji?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LegacyRoomChannel = {
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
position?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LegacyRoomMember = {
|
||||||
|
id?: unknown;
|
||||||
|
oderId?: unknown;
|
||||||
|
username?: unknown;
|
||||||
|
displayName?: unknown;
|
||||||
|
avatarUrl?: unknown;
|
||||||
|
role?: unknown;
|
||||||
|
joinedAt?: unknown;
|
||||||
|
lastSeenAt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArray<T>(raw: string | null): T[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw || '[]');
|
||||||
|
|
||||||
|
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelName(name: string): string {
|
||||||
|
return name.trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelNameKey(type: ChannelType, name: string): string {
|
||||||
|
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberKey(member: { id?: string; oderId?: string }): string {
|
||||||
|
return member.oderId?.trim() || member.id?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||||
|
return member.displayName || member.username || member.oderId || member.id || 'User';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||||
|
const base = fallbackDisplayName(member)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
return base || member.oderId || member.id || 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
||||||
|
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
||||||
|
? value
|
||||||
|
: 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRoomMemberRole(
|
||||||
|
existingRole: RoomMemberRole,
|
||||||
|
incomingRole: RoomMemberRole,
|
||||||
|
preferIncoming: boolean
|
||||||
|
): RoomMemberRole {
|
||||||
|
if (existingRole === incomingRole) {
|
||||||
|
return existingRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingRole === 'member' && existingRole !== 'member') {
|
||||||
|
return existingRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRole === 'member' && incomingRole !== 'member') {
|
||||||
|
return incomingRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferIncoming ? incomingRole : existingRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRoomMembers(
|
||||||
|
firstMember: {
|
||||||
|
id: string;
|
||||||
|
oderId?: string;
|
||||||
|
displayName: string;
|
||||||
|
},
|
||||||
|
secondMember: {
|
||||||
|
id: string;
|
||||||
|
oderId?: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
): number {
|
||||||
|
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
||||||
|
|
||||||
|
if (displayNameCompare !== 0) {
|
||||||
|
return displayNameCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessageReactions(messageId: string, raw: string | null) {
|
||||||
|
const reactions = parseArray<LegacyReaction>(raw);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return reactions.flatMap((reaction) => {
|
||||||
|
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
|
||||||
|
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
|
||||||
|
const dedupeKey = `${userId}:${emoji}`;
|
||||||
|
|
||||||
|
if (!emoji || seen.has(dedupeKey)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
|
||||||
|
messageId,
|
||||||
|
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
|
||||||
|
userId: userId || null,
|
||||||
|
emoji,
|
||||||
|
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoomChannels(raw: string | null) {
|
||||||
|
const channels = parseArray<LegacyRoomChannel>(raw);
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
|
||||||
|
return channels.flatMap((channel, index) => {
|
||||||
|
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||||
|
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||||
|
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||||
|
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||||
|
const nameKey = type ? channelNameKey(type, name) : '';
|
||||||
|
|
||||||
|
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIds.add(id);
|
||||||
|
seenNames.add(nameKey);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
channelId: id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
position
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
|
||||||
|
const members = parseArray<LegacyRoomMember>(raw);
|
||||||
|
const membersByKey = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
oderId?: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
role: RoomMemberRole;
|
||||||
|
joinedAt: number;
|
||||||
|
lastSeenAt: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const rawMember of members) {
|
||||||
|
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
|
||||||
|
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
|
||||||
|
const key = normalizedOderId || normalizedId;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeenAt = isFiniteNumber(rawMember.lastSeenAt)
|
||||||
|
? rawMember.lastSeenAt
|
||||||
|
: isFiniteNumber(rawMember.joinedAt)
|
||||||
|
? rawMember.joinedAt
|
||||||
|
: now;
|
||||||
|
const joinedAt = isFiniteNumber(rawMember.joinedAt) ? rawMember.joinedAt : lastSeenAt;
|
||||||
|
const username = typeof rawMember.username === 'string' ? rawMember.username.trim() : '';
|
||||||
|
const displayName = typeof rawMember.displayName === 'string' ? rawMember.displayName.trim() : '';
|
||||||
|
const avatarUrl = typeof rawMember.avatarUrl === 'string' ? rawMember.avatarUrl.trim() : '';
|
||||||
|
const nextMember = {
|
||||||
|
id: normalizedId || key,
|
||||||
|
oderId: normalizedOderId || undefined,
|
||||||
|
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
|
||||||
|
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
|
||||||
|
avatarUrl: avatarUrl || undefined,
|
||||||
|
role: normalizeRoomMemberRole(rawMember.role),
|
||||||
|
joinedAt,
|
||||||
|
lastSeenAt
|
||||||
|
};
|
||||||
|
const existingMember = membersByKey.get(key);
|
||||||
|
|
||||||
|
if (!existingMember) {
|
||||||
|
membersByKey.set(key, nextMember);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||||
|
|
||||||
|
membersByKey.set(key, {
|
||||||
|
id: existingMember.id || nextMember.id,
|
||||||
|
oderId: nextMember.oderId || existingMember.oderId,
|
||||||
|
username: preferIncoming
|
||||||
|
? (nextMember.username || existingMember.username)
|
||||||
|
: (existingMember.username || nextMember.username),
|
||||||
|
displayName: preferIncoming
|
||||||
|
? (nextMember.displayName || existingMember.displayName)
|
||||||
|
: (existingMember.displayName || nextMember.displayName),
|
||||||
|
avatarUrl: preferIncoming
|
||||||
|
? (nextMember.avatarUrl || existingMember.avatarUrl)
|
||||||
|
: (existingMember.avatarUrl || nextMember.avatarUrl),
|
||||||
|
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
|
||||||
|
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
|
||||||
|
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
|
||||||
|
name = 'NormalizeArrayColumns1000000000003';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "room_channels" (
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("roomId", "channelId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "room_members" (
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"memberKey" TEXT NOT NULL,
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"oderId" TEXT,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT NOT NULL,
|
||||||
|
"avatarUrl" TEXT,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"joinedAt" INTEGER NOT NULL,
|
||||||
|
"lastSeenAt" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("roomId", "memberKey")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
|
||||||
|
|
||||||
|
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
|
||||||
|
|
||||||
|
for (const row of messageRows) {
|
||||||
|
const reactions = normalizeMessageReactions(row.id, row.reactions);
|
||||||
|
|
||||||
|
for (const reaction of reactions) {
|
||||||
|
const existing = await queryRunner.query(
|
||||||
|
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
|
||||||
|
[reaction.messageId, reaction.userId, reaction.emoji]
|
||||||
|
) as Array<{ 1: number }>;
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
|
||||||
|
|
||||||
|
for (const row of roomRows) {
|
||||||
|
for (const channel of normalizeRoomChannels(row.channels)) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of normalizeRoomMembers(row.members)) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
row.id,
|
||||||
|
memberKey(member),
|
||||||
|
member.id,
|
||||||
|
member.oderId ?? null,
|
||||||
|
member.username,
|
||||||
|
member.displayName,
|
||||||
|
member.avatarUrl ?? null,
|
||||||
|
member.role,
|
||||||
|
member.joinedAt,
|
||||||
|
member.lastSeenAt
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "messages_next" (
|
||||||
|
"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,
|
||||||
|
"isDeleted" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"replyToId" TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
|
||||||
|
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
|
||||||
|
FROM "messages"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE "messages"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "rooms_next" (
|
||||||
|
"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,
|
||||||
|
"hasPassword" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sourceId" TEXT,
|
||||||
|
"sourceName" TEXT,
|
||||||
|
"sourceUrl" TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
|
||||||
|
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
|
||||||
|
FROM "rooms"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE "rooms"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
type LegacyRoomRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
topic: string | null;
|
||||||
|
hostId: string;
|
||||||
|
password: string | null;
|
||||||
|
hasPassword: number;
|
||||||
|
isPrivate: number;
|
||||||
|
createdAt: number;
|
||||||
|
userCount: number;
|
||||||
|
maxUsers: number | null;
|
||||||
|
icon: string | null;
|
||||||
|
iconUpdatedAt: number | null;
|
||||||
|
permissions: string | null;
|
||||||
|
sourceId: string | null;
|
||||||
|
sourceName: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomMemberRow = {
|
||||||
|
roomId: string;
|
||||||
|
memberKey: string;
|
||||||
|
id: string;
|
||||||
|
oderId: string | null;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LegacyRoomPermissions = {
|
||||||
|
adminsManageRooms?: boolean;
|
||||||
|
moderatorsManageRooms?: boolean;
|
||||||
|
adminsManageIcon?: boolean;
|
||||||
|
moderatorsManageIcon?: boolean;
|
||||||
|
allowVoice?: boolean;
|
||||||
|
allowScreenShare?: boolean;
|
||||||
|
allowFileUploads?: boolean;
|
||||||
|
slowModeInterval?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_ROLE_IDS = {
|
||||||
|
everyone: 'system-everyone',
|
||||||
|
moderator: 'system-moderator',
|
||||||
|
admin: 'system-admin'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function parseLegacyPermissions(rawPermissions: string | null): LegacyRoomPermissions {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawPermissions || '{}') as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
adminsManageRooms: parsed['adminsManageRooms'] === true,
|
||||||
|
moderatorsManageRooms: parsed['moderatorsManageRooms'] === true,
|
||||||
|
adminsManageIcon: parsed['adminsManageIcon'] === true,
|
||||||
|
moderatorsManageIcon: parsed['moderatorsManageIcon'] === true,
|
||||||
|
allowVoice: parsed['allowVoice'] !== false,
|
||||||
|
allowScreenShare: parsed['allowScreenShare'] !== false,
|
||||||
|
allowFileUploads: parsed['allowFileUploads'] !== false,
|
||||||
|
slowModeInterval: typeof parsed['slowModeInterval'] === 'number' && Number.isFinite(parsed['slowModeInterval'])
|
||||||
|
? parsed['slowModeInterval']
|
||||||
|
: 0
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
allowVoice: true,
|
||||||
|
allowScreenShare: true,
|
||||||
|
allowFileUploads: true,
|
||||||
|
slowModeInterval: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
roleId: SYSTEM_ROLE_IDS.everyone,
|
||||||
|
name: '@everyone',
|
||||||
|
color: '#6b7280',
|
||||||
|
position: 0,
|
||||||
|
isSystem: 1,
|
||||||
|
manageServer: 'inherit',
|
||||||
|
manageRoles: 'inherit',
|
||||||
|
manageChannels: 'inherit',
|
||||||
|
manageIcon: 'inherit',
|
||||||
|
kickMembers: 'inherit',
|
||||||
|
banMembers: 'inherit',
|
||||||
|
manageBans: 'inherit',
|
||||||
|
deleteMessages: 'inherit',
|
||||||
|
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
|
||||||
|
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
|
||||||
|
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roleId: SYSTEM_ROLE_IDS.moderator,
|
||||||
|
name: 'Moderator',
|
||||||
|
color: '#10b981',
|
||||||
|
position: 200,
|
||||||
|
isSystem: 1,
|
||||||
|
manageServer: 'inherit',
|
||||||
|
manageRoles: 'inherit',
|
||||||
|
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
|
||||||
|
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit',
|
||||||
|
kickMembers: 'allow',
|
||||||
|
banMembers: 'inherit',
|
||||||
|
manageBans: 'inherit',
|
||||||
|
deleteMessages: 'allow',
|
||||||
|
joinVoice: 'inherit',
|
||||||
|
shareScreen: 'inherit',
|
||||||
|
uploadFiles: 'inherit'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roleId: SYSTEM_ROLE_IDS.admin,
|
||||||
|
name: 'Admin',
|
||||||
|
color: '#60a5fa',
|
||||||
|
position: 300,
|
||||||
|
isSystem: 1,
|
||||||
|
manageServer: 'inherit',
|
||||||
|
manageRoles: 'inherit',
|
||||||
|
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
|
||||||
|
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit',
|
||||||
|
kickMembers: 'allow',
|
||||||
|
banMembers: 'allow',
|
||||||
|
manageBans: 'allow',
|
||||||
|
deleteMessages: 'allow',
|
||||||
|
joinVoice: 'inherit',
|
||||||
|
shareScreen: 'inherit',
|
||||||
|
uploadFiles: 'inherit'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleIdsForMemberRole(role: string): string[] {
|
||||||
|
if (role === 'admin') {
|
||||||
|
return [SYSTEM_ROLE_IDS.admin];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'moderator') {
|
||||||
|
return [SYSTEM_ROLE_IDS.moderator];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NormalizeRoomAccessControl1000000000004 implements MigrationInterface {
|
||||||
|
name = 'NormalizeRoomAccessControl1000000000004';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "room_roles" (
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"color" TEXT,
|
||||||
|
"position" INTEGER NOT NULL,
|
||||||
|
"isSystem" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
PRIMARY KEY ("roomId", "roleId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_roles_roomId" ON "room_roles" ("roomId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "room_user_roles" (
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"userKey" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"oderId" TEXT,
|
||||||
|
PRIMARY KEY ("roomId", "userKey", "roleId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_user_roles_roomId" ON "room_user_roles" ("roomId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "room_channel_permissions" (
|
||||||
|
"roomId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"targetType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT NOT NULL,
|
||||||
|
"permission" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
PRIMARY KEY ("roomId", "channelId", "targetType", "targetId", "permission")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channel_permissions_roomId" ON "room_channel_permissions" ("roomId")`);
|
||||||
|
|
||||||
|
const rooms = await queryRunner.query(`
|
||||||
|
SELECT "id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "sourceId", "sourceName", "sourceUrl"
|
||||||
|
FROM "rooms"
|
||||||
|
`) as LegacyRoomRow[];
|
||||||
|
const members = await queryRunner.query(`
|
||||||
|
SELECT "roomId", "memberKey", "id", "oderId", "role"
|
||||||
|
FROM "room_members"
|
||||||
|
`) as RoomMemberRow[];
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
const legacyPermissions = parseLegacyPermissions(room.permissions);
|
||||||
|
const roles = buildDefaultRoomRoles(legacyPermissions);
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT OR REPLACE INTO "room_roles" ("roomId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
room.id,
|
||||||
|
role.roleId,
|
||||||
|
role.name,
|
||||||
|
role.color,
|
||||||
|
role.position,
|
||||||
|
role.isSystem,
|
||||||
|
role.manageServer,
|
||||||
|
role.manageRoles,
|
||||||
|
role.manageChannels,
|
||||||
|
role.manageIcon,
|
||||||
|
role.kickMembers,
|
||||||
|
role.banMembers,
|
||||||
|
role.manageBans,
|
||||||
|
role.deleteMessages,
|
||||||
|
role.joinVoice,
|
||||||
|
role.shareScreen,
|
||||||
|
role.uploadFiles
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members.filter((candidateMember) => candidateMember.roomId === room.id)) {
|
||||||
|
for (const roleId of roleIdsForMemberRole(member.role)) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT OR REPLACE INTO "room_user_roles" ("roomId", "userKey", "roleId", "userId", "oderId") VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
room.id,
|
||||||
|
member.memberKey,
|
||||||
|
roleId,
|
||||||
|
member.id,
|
||||||
|
member.oderId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "rooms_next" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"topic" TEXT,
|
||||||
|
"hostId" TEXT NOT NULL,
|
||||||
|
"password" TEXT,
|
||||||
|
"hasPassword" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" INTEGER NOT NULL,
|
||||||
|
"userCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"maxUsers" INTEGER,
|
||||||
|
"icon" TEXT,
|
||||||
|
"iconUpdatedAt" INTEGER,
|
||||||
|
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sourceId" TEXT,
|
||||||
|
"sourceName" TEXT,
|
||||||
|
"sourceUrl" TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
const legacyPermissions = parseLegacyPermissions(room.permissions);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "slowModeInterval", "sourceId", "sourceName", "sourceUrl") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
room.id,
|
||||||
|
room.name,
|
||||||
|
room.description,
|
||||||
|
room.topic,
|
||||||
|
room.hostId,
|
||||||
|
room.password,
|
||||||
|
room.hasPassword,
|
||||||
|
room.isPrivate,
|
||||||
|
room.createdAt,
|
||||||
|
room.userCount,
|
||||||
|
room.maxUsers,
|
||||||
|
room.icon,
|
||||||
|
room.iconUpdatedAt,
|
||||||
|
legacyPermissions.slowModeInterval ?? 0,
|
||||||
|
room.sourceId,
|
||||||
|
room.sourceName,
|
||||||
|
room.sourceUrl
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TABLE "rooms"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_channel_permissions"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_user_roles"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_roles"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddLinkMetadata1000000000005 implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "linkMetadata" text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// SQLite does not support DROP COLUMN; column is nullable and harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monit
|
|||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||||
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -50,6 +51,12 @@ export interface DesktopUpdateServerContext {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateServerHealthSnapshot {
|
||||||
|
manifestUrl: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopUpdateState {
|
export interface DesktopUpdateState {
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
availableVersions: string[];
|
availableVersions: string[];
|
||||||
@@ -84,6 +91,23 @@ export interface DesktopUpdateState {
|
|||||||
targetVersion: string | null;
|
targetVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopNotificationPayload {
|
||||||
|
body: string;
|
||||||
|
requestAttention: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowStateSnapshot {
|
||||||
|
isFocused: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedThemeFileDescriptor {
|
||||||
|
fileName: string;
|
||||||
|
modifiedAt: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
function readLinuxDisplayServer(): string {
|
function readLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -100,6 +124,22 @@ function readLinuxDisplayServer(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuParams {
|
||||||
|
posX: number;
|
||||||
|
posY: number;
|
||||||
|
isEditable: boolean;
|
||||||
|
selectionText: string;
|
||||||
|
linkURL: string;
|
||||||
|
mediaType: string;
|
||||||
|
srcURL: string;
|
||||||
|
editFlags: {
|
||||||
|
canCut: boolean;
|
||||||
|
canCopy: boolean;
|
||||||
|
canPaste: boolean;
|
||||||
|
canSelectAll: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -116,17 +156,28 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
getSavedThemesPath: () => Promise<string>;
|
||||||
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
|
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||||
|
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||||
consumePendingDeepLink: () => Promise<string | null>;
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
getDesktopSettings: () => Promise<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
runtimeHardwareAcceleration: boolean;
|
runtimeHardwareAcceleration: boolean;
|
||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||||
|
requestWindowAttention: () => Promise<boolean>;
|
||||||
|
clearWindowAttention: () => Promise<boolean>;
|
||||||
|
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||||
|
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||||
restartToApplyUpdate: () => Promise<boolean>;
|
restartToApplyUpdate: () => Promise<boolean>;
|
||||||
@@ -134,6 +185,7 @@ export interface ElectronAPI {
|
|||||||
setDesktopSettings: (patch: {
|
setDesktopSettings: (patch: {
|
||||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
|
closeToTray?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
@@ -141,6 +193,7 @@ export interface ElectronAPI {
|
|||||||
}) => Promise<{
|
}) => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -157,6 +210,9 @@ export interface ElectronAPI {
|
|||||||
deleteFile: (filePath: string) => Promise<boolean>;
|
deleteFile: (filePath: string) => Promise<boolean>;
|
||||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||||
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -204,9 +260,29 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
|
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||||
|
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||||
|
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||||
|
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||||
|
deleteSavedTheme: (fileName) => ipcRenderer.invoke('delete-saved-theme', fileName),
|
||||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||||
|
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
|
||||||
|
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
|
||||||
|
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
|
||||||
|
onWindowStateChanged: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
|
||||||
|
listener(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||||
|
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
|
||||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||||
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
||||||
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
||||||
@@ -242,6 +318,19 @@ const electronAPI: ElectronAPI = {
|
|||||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||||
|
|
||||||
|
onContextMenu: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => {
|
||||||
|
listener(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on('show-context-menu', wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener('show-context-menu', wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||||
|
|
||||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||||
};
|
};
|
||||||
|
|||||||
91
electron/theme-library.ts
Normal file
91
electron/theme-library.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface SavedThemeFileDescriptor {
|
||||||
|
fileName: string;
|
||||||
|
modifiedAt: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVED_THEME_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
||||||
|
|
||||||
|
function resolveSavedThemesPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'themes');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSavedThemesPath(): Promise<string> {
|
||||||
|
const themesPath = resolveSavedThemesPath();
|
||||||
|
|
||||||
|
await fsp.mkdir(themesPath, { recursive: true });
|
||||||
|
|
||||||
|
return themesPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSavedThemeFileName(fileName: string): string {
|
||||||
|
const normalized = typeof fileName === 'string'
|
||||||
|
? fileName.trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!SAVED_THEME_FILE_NAME_PATTERN.test(normalized) || normalized.includes('..')) {
|
||||||
|
throw new Error('Invalid saved theme file name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSavedThemeFilePath(fileName: string): Promise<string> {
|
||||||
|
const themesPath = await ensureSavedThemesPath();
|
||||||
|
|
||||||
|
return path.join(themesPath, assertSavedThemeFileName(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSavedThemesPath(): Promise<string> {
|
||||||
|
return await ensureSavedThemesPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
|
||||||
|
const themesPath = await ensureSavedThemesPath();
|
||||||
|
const entries = await fsp.readdir(themesPath, { withFileTypes: true });
|
||||||
|
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
|
||||||
|
const descriptors = await Promise.all(files.map(async (entry) => {
|
||||||
|
const filePath = path.join(themesPath, entry.name);
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: entry.name,
|
||||||
|
modifiedAt: Math.round(stats.mtimeMs),
|
||||||
|
path: filePath
|
||||||
|
} satisfies SavedThemeFileDescriptor;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return descriptors.sort((left, right) => right.modifiedAt - left.modifiedAt || left.fileName.localeCompare(right.fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSavedTheme(fileName: string): Promise<string> {
|
||||||
|
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||||
|
|
||||||
|
return await fsp.readFile(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSavedTheme(fileName: string, text: string): Promise<boolean> {
|
||||||
|
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||||
|
|
||||||
|
await fsp.writeFile(filePath, text, 'utf8');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSavedTheme(fileName: string): Promise<boolean> {
|
||||||
|
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code === 'ENOENT') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServerHealthResponse {
|
||||||
|
releaseManifestUrl?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UpdateVersionInfo {
|
interface UpdateVersionInfo {
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateServerHealthSnapshot {
|
||||||
|
manifestUrl: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopUpdateState {
|
export interface DesktopUpdateState {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
availableVersions: string[];
|
availableVersions: string[];
|
||||||
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
|
|||||||
|
|
||||||
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
|
|
||||||
|
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
let currentCheckPromise: Promise<void> | null = null;
|
let currentCheckPromise: Promise<void> | null = null;
|
||||||
let currentContext: DesktopUpdateServerContext = {
|
let currentContext: DesktopUpdateServerContext = {
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
|
|||||||
return parseReleaseManifest(payload);
|
return parseReleaseManifest(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
|
||||||
|
return {
|
||||||
|
manifestUrl: null,
|
||||||
|
serverVersion: null,
|
||||||
|
serverVersionStatus: 'unavailable'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||||
|
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
|
||||||
|
|
||||||
|
if (!sanitizedServerUrl) {
|
||||||
|
return createUnavailableServerHealthSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return createUnavailableServerHealthSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json() as ServerHealthResponse;
|
||||||
|
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
|
||||||
|
serverVersion,
|
||||||
|
serverVersionStatus: serverVersion ? 'reported' : 'missing'
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return createUnavailableServerHealthSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatManifestLoadErrors(errors: string[]): string {
|
function formatManifestLoadErrors(errors: string[]): string {
|
||||||
if (errors.length === 0) {
|
if (errors.length === 0) {
|
||||||
return 'No valid release manifest could be loaded.';
|
return 'No valid release manifest could be loaded.';
|
||||||
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
|
|||||||
return desktopUpdateState;
|
return desktopUpdateState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readDesktopUpdateServerHealth(
|
||||||
|
serverUrl: string
|
||||||
|
): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||||
|
return await loadServerHealth(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
export function restartToApplyUpdate(): boolean {
|
export function restartToApplyUpdate(): boolean {
|
||||||
if (!desktopUpdateState.restartRequired) {
|
if (!desktopUpdateState.restartRequired) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ import {
|
|||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
|
Menu,
|
||||||
session,
|
session,
|
||||||
shell
|
shell,
|
||||||
|
Tray
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
let closeToTrayEnabled = true;
|
||||||
|
let appQuitting = false;
|
||||||
|
|
||||||
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
|
|
||||||
function getAssetPath(...segments: string[]): string {
|
function getAssetPath(...segments: string[]): string {
|
||||||
const basePath = app.isPackaged
|
const basePath = app.isPackaged
|
||||||
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
|
|||||||
return getExistingAssetPath('macos', '1024x1024.png');
|
return getExistingAssetPath('macos', '1024x1024.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTrayIconPath(): string | undefined {
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
return getExistingAssetPath('windows', 'icon.ico');
|
||||||
|
|
||||||
|
return getExistingAssetPath('icon.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getWindowIconPath };
|
||||||
|
|
||||||
export function getMainWindow(): BrowserWindow | null {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow;
|
return mainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function destroyTray(): void {
|
||||||
|
if (!tray) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tray.destroy();
|
||||||
|
tray = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestAppQuit(): void {
|
||||||
|
prepareWindowForAppQuit();
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTray(): void {
|
||||||
|
if (tray) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trayIconPath = getTrayIconPath();
|
||||||
|
|
||||||
|
if (!trayIconPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tray = new Tray(trayIconPath);
|
||||||
|
tray.setToolTip('MetoYou');
|
||||||
|
tray.setContextMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Open MetoYou',
|
||||||
|
click: () => {
|
||||||
|
void showMainWindow();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'separator'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Close MetoYou',
|
||||||
|
click: () => {
|
||||||
|
requestAppQuit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
tray.on('click', () => {
|
||||||
|
void showMainWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWindowToTray(): void {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.hide();
|
||||||
|
emitWindowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCloseToTraySetting(enabled: boolean): void {
|
||||||
|
closeToTrayEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareWindowForAppQuit(): void {
|
||||||
|
appQuitting = true;
|
||||||
|
destroyTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showMainWindow(): Promise<void> {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
await createWindow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainWindow.isVisible()) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.focus();
|
||||||
|
emitWindowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitWindowState(): void {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
|
||||||
|
isFocused: mainWindow.isFocused(),
|
||||||
|
isMinimized: mainWindow.isMinimized()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createWindow(): Promise<void> {
|
export async function createWindow(): Promise<void> {
|
||||||
const windowIconPath = getWindowIconPath();
|
const windowIconPath = getWindowIconPath();
|
||||||
|
|
||||||
|
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||||
|
ensureTray();
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -105,10 +224,64 @@ export async function createWindow(): Promise<void> {
|
|||||||
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
|
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (appQuitting || !closeToTrayEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
hideWindowToTray();
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on('focus', () => {
|
||||||
|
mainWindow?.flashFrame(false);
|
||||||
|
emitWindowState();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('blur', () => {
|
||||||
|
emitWindowState();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('minimize', () => {
|
||||||
|
emitWindowState();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('restore', () => {
|
||||||
|
emitWindowState();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('show', () => {
|
||||||
|
emitWindowState();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('hide', () => {
|
||||||
|
emitWindowState();
|
||||||
|
});
|
||||||
|
|
||||||
|
emitWindowState();
|
||||||
|
|
||||||
|
mainWindow.webContents.on('context-menu', (_event, params) => {
|
||||||
|
mainWindow?.webContents.send('show-context-menu', {
|
||||||
|
posX: params.x,
|
||||||
|
posY: params.y,
|
||||||
|
isEditable: params.isEditable,
|
||||||
|
selectionText: params.selectionText,
|
||||||
|
linkURL: params.linkURL,
|
||||||
|
mediaType: params.mediaType,
|
||||||
|
srcURL: params.srcURL,
|
||||||
|
editFlags: {
|
||||||
|
canCut: params.editFlags.canCut,
|
||||||
|
canCopy: params.editFlags.canCopy,
|
||||||
|
canPaste: params.editFlags.canPaste,
|
||||||
|
canSelectAll: params.editFlags.canSelectAll
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ module.exports = tseslint.config(
|
|||||||
},
|
},
|
||||||
// HTML template formatting rules (external Angular templates only)
|
// HTML template formatting rules (external Angular templates only)
|
||||||
{
|
{
|
||||||
files: ['src/app/**/*.html'],
|
files: ['toju-app/src/app/**/*.html'],
|
||||||
plugins: { 'no-dashes': noDashPlugin },
|
plugins: { 'no-dashes': noDashPlugin },
|
||||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
184
package-lock.json
generated
184
package-lock.json
generated
@@ -14,6 +14,12 @@
|
|||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.41.0",
|
||||||
"@ng-icons/core": "^33.0.0",
|
"@ng-icons/core": "^33.0.0",
|
||||||
"@ng-icons/lucide": "^33.0.0",
|
"@ng-icons/lucide": "^33.0.0",
|
||||||
"@ngrx/effects": "^21.0.1",
|
"@ngrx/effects": "^21.0.1",
|
||||||
@@ -27,6 +33,7 @@
|
|||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
@@ -2697,6 +2704,109 @@
|
|||||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||||
|
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||||
|
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-json": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@lezer/json": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
|
||||||
|
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.35.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/theme-one-dark": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
|
||||||
|
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@develar/schema-utils": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -5672,6 +5782,41 @@
|
|||||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/json": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
|
||||||
|
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||||
@@ -5865,6 +6010,12 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@mermaid-js/parser": {
|
"node_modules/@mermaid-js/parser": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
||||||
@@ -14138,6 +14289,21 @@
|
|||||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -14766,6 +14932,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cron-parser": {
|
"node_modules/cron-parser": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
@@ -27782,6 +27954,12 @@
|
|||||||
"webpack": "^5.0.0"
|
"webpack": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stylehacks": {
|
"node_modules/stylehacks": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
||||||
@@ -30374,6 +30552,12 @@
|
|||||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/wait-on": {
|
"node_modules/wait-on": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -7,22 +7,22 @@
|
|||||||
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
||||||
"main": "dist/electron/main.js",
|
"main": "dist/electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "cd \"toju-app\" && ng",
|
||||||
"prebuild": "npm run bundle:rnnoise",
|
"prebuild": "npm run bundle:rnnoise",
|
||||||
"prestart": "npm run bundle:rnnoise",
|
"prestart": "npm run bundle:rnnoise",
|
||||||
"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=toju-app/public/rnnoise-worklet.js",
|
||||||
"start": "ng serve",
|
"start": "cd \"toju-app\" && ng serve",
|
||||||
"build": "ng build",
|
"build": "cd \"toju-app\" && ng build",
|
||||||
"build:electron": "tsc -p tsconfig.electron.json",
|
"build:electron": "tsc -p tsconfig.electron.json",
|
||||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod": "ng build --configuration production --base-href='./'",
|
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "cd \"toju-app\" && 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 && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
||||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --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 node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
"dev:app": "npm run electron:dev",
|
"dev:app": "npm run electron:dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||||
"format": "prettier --write \"src/app/**/*.html\"",
|
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||||
"format:check": "prettier --check \"src/app/**/*.html\"",
|
"format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
|
||||||
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
|
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
|
||||||
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
|
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
|
||||||
"release:manifest": "node tools/generate-release-manifest.js",
|
"release:manifest": "node tools/generate-release-manifest.js",
|
||||||
@@ -60,6 +60,12 @@
|
|||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.41.0",
|
||||||
"@ng-icons/core": "^33.0.0",
|
"@ng-icons/core": "^33.0.0",
|
||||||
"@ng-icons/lucide": "^33.0.0",
|
"@ng-icons/lucide": "^33.0.0",
|
||||||
"@ngrx/effects": "^21.0.1",
|
"@ngrx/effects": "^21.0.1",
|
||||||
@@ -73,6 +79,7 @@
|
|||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
|
|||||||
Binary file not shown.
@@ -4,18 +4,28 @@ import { resolveRuntimePath } from '../runtime-paths';
|
|||||||
|
|
||||||
export type ServerHttpProtocol = 'http' | 'https';
|
export type ServerHttpProtocol = 'http' | 'https';
|
||||||
|
|
||||||
|
export interface LinkPreviewConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
cacheTtlMinutes: number;
|
||||||
|
maxCacheSizeMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
serverPort: number;
|
||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
|
linkPreview: LinkPreviewConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||||
const DEFAULT_SERVER_PORT = 3001;
|
const DEFAULT_SERVER_PORT = 3001;
|
||||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||||
|
const DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES = 7200;
|
||||||
|
const DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
const HARD_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
|
||||||
function normalizeKlipyApiKey(value: unknown): string {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -66,6 +76,27 @@ function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): nu
|
|||||||
: fallback;
|
: fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
||||||
|
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const enabled = typeof raw.enabled === 'boolean'
|
||||||
|
? raw.enabled
|
||||||
|
: true;
|
||||||
|
const cacheTtl = typeof raw.cacheTtlMinutes === 'number'
|
||||||
|
&& Number.isFinite(raw.cacheTtlMinutes)
|
||||||
|
&& raw.cacheTtlMinutes >= 0
|
||||||
|
? raw.cacheTtlMinutes
|
||||||
|
: DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES;
|
||||||
|
const maxSize = typeof raw.maxCacheSizeMb === 'number'
|
||||||
|
&& Number.isFinite(raw.maxCacheSizeMb)
|
||||||
|
&& raw.maxCacheSizeMb >= 0
|
||||||
|
? Math.min(raw.maxCacheSizeMb, HARD_MAX_CACHE_SIZE_MB)
|
||||||
|
: DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB;
|
||||||
|
|
||||||
|
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||||
|
}
|
||||||
|
|
||||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -111,7 +142,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||||
|
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -124,7 +156,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
serverHost: normalized.serverHost
|
serverHost: normalized.serverHost,
|
||||||
|
linkPreview: normalized.linkPreview
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +202,7 @@ export function getServerHost(): string | undefined {
|
|||||||
export function isHttpsServerEnabled(): boolean {
|
export function isHttpsServerEnabled(): boolean {
|
||||||
return getServerProtocol() === 'https';
|
return getServerProtocol() === 'https';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||||
|
return getVariablesConfig().linkPreview;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerTagEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
@@ -11,9 +16,16 @@ import { DeleteServerCommand } from '../../types';
|
|||||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { serverId } = command.payload;
|
const { serverId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
await dataSource.transaction(async (manager) => {
|
||||||
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
await manager.getRepository(ServerTagEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
await manager.getRepository(ServerChannelEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
await manager.getRepository(ServerRoleEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
await manager.getRepository(ServerUserRoleEntity).delete({ serverId });
|
||||||
|
await manager.getRepository(ServerChannelPermissionEntity).delete({ serverId });
|
||||||
|
await manager.getRepository(JoinRequestEntity).delete({ serverId });
|
||||||
|
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||||
|
await manager.getRepository(ServerInviteEntity).delete({ serverId });
|
||||||
|
await manager.getRepository(ServerBanEntity).delete({ serverId });
|
||||||
|
await manager.getRepository(ServerEntity).delete(serverId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity } from '../../../entities';
|
import { ServerEntity } from '../../../entities';
|
||||||
|
import { replaceServerRelations } from '../../relations';
|
||||||
import { UpsertServerCommand } from '../../types';
|
import { UpsertServerCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(ServerEntity);
|
|
||||||
const { server } = command.payload;
|
const { server } = command.payload;
|
||||||
const entity = repo.create({
|
|
||||||
id: server.id,
|
|
||||||
name: server.name,
|
|
||||||
description: server.description ?? null,
|
|
||||||
ownerId: server.ownerId,
|
|
||||||
ownerPublicKey: server.ownerPublicKey,
|
|
||||||
passwordHash: server.passwordHash ?? null,
|
|
||||||
isPrivate: server.isPrivate ? 1 : 0,
|
|
||||||
maxUsers: server.maxUsers,
|
|
||||||
currentUsers: server.currentUsers,
|
|
||||||
tags: JSON.stringify(server.tags),
|
|
||||||
createdAt: server.createdAt,
|
|
||||||
lastSeen: server.lastSeen
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(entity);
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(ServerEntity);
|
||||||
|
const entity = repo.create({
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
description: server.description ?? null,
|
||||||
|
ownerId: server.ownerId,
|
||||||
|
ownerPublicKey: server.ownerPublicKey,
|
||||||
|
passwordHash: server.passwordHash ?? null,
|
||||||
|
isPrivate: server.isPrivate ? 1 : 0,
|
||||||
|
maxUsers: server.maxUsers,
|
||||||
|
currentUsers: server.currentUsers,
|
||||||
|
slowModeInterval: server.slowModeInterval ?? 0,
|
||||||
|
createdAt: server.createdAt,
|
||||||
|
lastSeen: server.lastSeen
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
await replaceServerRelations(manager, server.id, {
|
||||||
|
tags: server.tags,
|
||||||
|
channels: server.channels ?? [],
|
||||||
|
roles: server.roles ?? [],
|
||||||
|
roleAssignments: server.roleAssignments ?? [],
|
||||||
|
channelPermissions: server.channelPermissions ?? []
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ServerPayload,
|
ServerPayload,
|
||||||
JoinRequestPayload
|
JoinRequestPayload
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { relationRecordToServerPayload } from './relations';
|
||||||
|
|
||||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||||
return {
|
return {
|
||||||
@@ -17,7 +18,24 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToServer(row: ServerEntity): ServerPayload {
|
export function rowToServer(
|
||||||
|
row: ServerEntity,
|
||||||
|
relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
|
||||||
|
tags: [],
|
||||||
|
channels: [],
|
||||||
|
roles: [],
|
||||||
|
roleAssignments: [],
|
||||||
|
channelPermissions: []
|
||||||
|
}
|
||||||
|
): ServerPayload {
|
||||||
|
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
|
||||||
|
tags: relations.tags ?? [],
|
||||||
|
channels: relations.channels ?? [],
|
||||||
|
roles: relations.roles ?? [],
|
||||||
|
roleAssignments: relations.roleAssignments ?? [],
|
||||||
|
channelPermissions: relations.channelPermissions ?? []
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -29,7 +47,12 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
|||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
maxUsers: row.maxUsers,
|
maxUsers: row.maxUsers,
|
||||||
currentUsers: row.currentUsers,
|
currentUsers: row.currentUsers,
|
||||||
tags: JSON.parse(row.tags || '[]'),
|
slowModeInterval: relationPayload.slowModeInterval,
|
||||||
|
tags: relationPayload.tags,
|
||||||
|
channels: relationPayload.channels,
|
||||||
|
roles: relationPayload.roles,
|
||||||
|
roleAssignments: relationPayload.roleAssignments,
|
||||||
|
channelPermissions: relationPayload.channelPermissions,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
lastSeen: row.lastSeen
|
lastSeen: row.lastSeen
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity } from '../../../entities';
|
import { ServerEntity } from '../../../entities';
|
||||||
import { rowToServer } from '../../mappers';
|
import { rowToServer } from '../../mappers';
|
||||||
|
import { loadServerRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(ServerEntity);
|
const repo = dataSource.getRepository(ServerEntity);
|
||||||
const rows = await repo.find({ where: { isPrivate: 0 } });
|
const rows = await repo.find({ where: { isPrivate: 0 } });
|
||||||
|
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
|
||||||
|
|
||||||
return rows.map(rowToServer);
|
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
|||||||
import { ServerEntity } from '../../../entities';
|
import { ServerEntity } from '../../../entities';
|
||||||
import { GetServerByIdQuery } from '../../types';
|
import { GetServerByIdQuery } from '../../types';
|
||||||
import { rowToServer } from '../../mappers';
|
import { rowToServer } from '../../mappers';
|
||||||
|
import { loadServerRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(ServerEntity);
|
const repo = dataSource.getRepository(ServerEntity);
|
||||||
const row = await repo.findOne({ where: { id: query.payload.serverId } });
|
const row = await repo.findOne({ where: { id: query.payload.serverId } });
|
||||||
|
|
||||||
return row ? rowToServer(row) : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
|
||||||
|
|
||||||
|
return rowToServer(row, relationsByServerId.get(row.id));
|
||||||
}
|
}
|
||||||
|
|||||||
603
server/src/cqrs/relations.ts
Normal file
603
server/src/cqrs/relations.ts
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
import {
|
||||||
|
DataSource,
|
||||||
|
EntityManager,
|
||||||
|
In
|
||||||
|
} from 'typeorm';
|
||||||
|
import {
|
||||||
|
ServerChannelEntity,
|
||||||
|
ServerTagEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
|
ServerChannelPermissionEntity
|
||||||
|
} from '../entities';
|
||||||
|
import {
|
||||||
|
AccessRolePayload,
|
||||||
|
ChannelPermissionPayload,
|
||||||
|
RoleAssignmentPayload,
|
||||||
|
ServerChannelPayload,
|
||||||
|
ServerPayload,
|
||||||
|
ServerPermissionKeyPayload,
|
||||||
|
PermissionStatePayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const SERVER_PERMISSION_KEYS: ServerPermissionKeyPayload[] = [
|
||||||
|
'manageServer',
|
||||||
|
'manageRoles',
|
||||||
|
'manageChannels',
|
||||||
|
'manageIcon',
|
||||||
|
'kickMembers',
|
||||||
|
'banMembers',
|
||||||
|
'manageBans',
|
||||||
|
'deleteMessages',
|
||||||
|
'joinVoice',
|
||||||
|
'shareScreen',
|
||||||
|
'uploadFiles'
|
||||||
|
];
|
||||||
|
const SYSTEM_ROLE_IDS = {
|
||||||
|
everyone: 'system-everyone',
|
||||||
|
moderator: 'system-moderator',
|
||||||
|
admin: 'system-admin'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface ServerRelationRecord {
|
||||||
|
tags: string[];
|
||||||
|
channels: ServerChannelPayload[];
|
||||||
|
roles: AccessRolePayload[];
|
||||||
|
roleAssignments: RoleAssignmentPayload[];
|
||||||
|
channelPermissions: ChannelPermissionPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelName(name: string): string {
|
||||||
|
return name.trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||||
|
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareText(firstValue: string, secondValue: string): number {
|
||||||
|
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: readonly string[] | undefined): string[] {
|
||||||
|
return Array.from(new Set((values ?? [])
|
||||||
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||||
|
.map((value) => value.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePermissionState(value: unknown): PermissionStatePayload {
|
||||||
|
return value === 'allow' || value === 'deny' || value === 'inherit'
|
||||||
|
? value
|
||||||
|
: 'inherit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> {
|
||||||
|
const matrix = rawMatrix && typeof rawMatrix === 'object'
|
||||||
|
? rawMatrix as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const normalized: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> = {};
|
||||||
|
|
||||||
|
for (const key of SERVER_PERMISSION_KEYS) {
|
||||||
|
const value = normalizePermissionState(matrix[key]);
|
||||||
|
|
||||||
|
if (value !== 'inherit') {
|
||||||
|
normalized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultServerRoles(): AccessRolePayload[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: SYSTEM_ROLE_IDS.everyone,
|
||||||
|
name: '@everyone',
|
||||||
|
color: '#6b7280',
|
||||||
|
position: 0,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: {
|
||||||
|
joinVoice: 'allow',
|
||||||
|
shareScreen: 'allow',
|
||||||
|
uploadFiles: 'allow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SYSTEM_ROLE_IDS.moderator,
|
||||||
|
name: 'Moderator',
|
||||||
|
color: '#10b981',
|
||||||
|
position: 200,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: {
|
||||||
|
kickMembers: 'allow',
|
||||||
|
deleteMessages: 'allow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SYSTEM_ROLE_IDS.admin,
|
||||||
|
name: 'Admin',
|
||||||
|
color: '#60a5fa',
|
||||||
|
position: 300,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: {
|
||||||
|
kickMembers: 'allow',
|
||||||
|
banMembers: 'allow',
|
||||||
|
manageBans: 'allow',
|
||||||
|
deleteMessages: 'allow',
|
||||||
|
manageChannels: 'allow',
|
||||||
|
manageIcon: 'allow'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerRole(rawRole: Partial<AccessRolePayload>, fallbackRole?: AccessRolePayload): AccessRolePayload | null {
|
||||||
|
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
|
||||||
|
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
|
||||||
|
|
||||||
|
if (!id || !name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
|
||||||
|
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
|
||||||
|
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
|
||||||
|
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRoles(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
|
||||||
|
if (firstRole.position !== secondRole.position) {
|
||||||
|
return firstRole.position - secondRole.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareText(firstRole.name, secondRole.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareAssignments(firstAssignment: RoleAssignmentPayload, secondAssignment: RoleAssignmentPayload): number {
|
||||||
|
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServerTags(rawTags: unknown): string[] {
|
||||||
|
if (!Array.isArray(rawTags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawTags
|
||||||
|
.filter((tag): tag is string => typeof tag === 'string')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
|
||||||
|
if (!Array.isArray(rawChannels)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const channels: ServerChannelPayload[] = [];
|
||||||
|
|
||||||
|
for (const [index, rawChannel] of rawChannels.entries()) {
|
||||||
|
if (!rawChannel || typeof rawChannel !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = rawChannel as Record<string, unknown>;
|
||||||
|
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
|
||||||
|
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
|
||||||
|
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
|
||||||
|
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
|
||||||
|
const nameKey = type ? channelNameKey(type, name) : '';
|
||||||
|
|
||||||
|
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIds.add(id);
|
||||||
|
seenNames.add(nameKey);
|
||||||
|
channels.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
position
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServerRoles(rawRoles: unknown): AccessRolePayload[] {
|
||||||
|
const rolesById = new Map<string, AccessRolePayload>();
|
||||||
|
|
||||||
|
if (Array.isArray(rawRoles)) {
|
||||||
|
for (const rawRole of rawRoles) {
|
||||||
|
if (!rawRole || typeof rawRole !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRole = normalizeServerRole(rawRole as Record<string, unknown>);
|
||||||
|
|
||||||
|
if (normalizedRole) {
|
||||||
|
rolesById.set(normalizedRole.id, normalizedRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const defaultRole of buildDefaultServerRoles()) {
|
||||||
|
const mergedRole = normalizeServerRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
|
||||||
|
|
||||||
|
rolesById.set(defaultRole.id, mergedRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(rolesById.values()).sort(compareRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServerRoleAssignments(rawAssignments: unknown, roles: readonly AccessRolePayload[]): RoleAssignmentPayload[] {
|
||||||
|
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||||
|
const assignmentsByKey = new Map<string, RoleAssignmentPayload>();
|
||||||
|
|
||||||
|
if (!Array.isArray(rawAssignments)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawAssignment of rawAssignments) {
|
||||||
|
if (!rawAssignment || typeof rawAssignment !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = rawAssignment as Record<string, unknown>;
|
||||||
|
const userId = typeof assignment['userId'] === 'string' ? assignment['userId'].trim() : '';
|
||||||
|
const oderId = typeof assignment['oderId'] === 'string' ? assignment['oderId'].trim() : undefined;
|
||||||
|
const key = oderId || userId;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIds = uniqueStrings(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
|
||||||
|
.filter((roleId) => validRoleIds.has(roleId));
|
||||||
|
|
||||||
|
if (roleIds.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentsByKey.set(key, {
|
||||||
|
userId: userId || key,
|
||||||
|
oderId,
|
||||||
|
roleIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServerChannelPermissions(
|
||||||
|
rawChannelPermissions: unknown,
|
||||||
|
roles: readonly AccessRolePayload[]
|
||||||
|
): ChannelPermissionPayload[] {
|
||||||
|
if (!Array.isArray(rawChannelPermissions)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRoleIds = new Set(roles.map((role) => role.id));
|
||||||
|
const overridesByKey = new Map<string, ChannelPermissionPayload>();
|
||||||
|
|
||||||
|
for (const rawOverride of rawChannelPermissions) {
|
||||||
|
if (!rawOverride || typeof rawOverride !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = rawOverride as Record<string, unknown>;
|
||||||
|
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
|
||||||
|
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
|
||||||
|
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
|
||||||
|
const permission = SERVER_PERMISSION_KEYS.find((key) => key === override['permission']);
|
||||||
|
const value = normalizePermissionState(override['value']);
|
||||||
|
|
||||||
|
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === 'role' && !validRoleIds.has(targetId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
|
||||||
|
|
||||||
|
overridesByKey.set(key, {
|
||||||
|
channelId,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
permission,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(overridesByKey.values()).sort((firstOverride, secondOverride) => {
|
||||||
|
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
|
||||||
|
|
||||||
|
if (channelCompare !== 0) {
|
||||||
|
return channelCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstOverride.targetType !== secondOverride.targetType) {
|
||||||
|
return compareText(firstOverride.targetType, secondOverride.targetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
|
||||||
|
|
||||||
|
if (targetCompare !== 0) {
|
||||||
|
return targetCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareText(firstOverride.permission, secondOverride.permission);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceServerRelations(
|
||||||
|
manager: EntityManager,
|
||||||
|
serverId: string,
|
||||||
|
options: {
|
||||||
|
tags: unknown;
|
||||||
|
channels: unknown;
|
||||||
|
roles?: unknown;
|
||||||
|
roleAssignments?: unknown;
|
||||||
|
channelPermissions?: unknown;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const tagRepo = manager.getRepository(ServerTagEntity);
|
||||||
|
const channelRepo = manager.getRepository(ServerChannelEntity);
|
||||||
|
const roleRepo = manager.getRepository(ServerRoleEntity);
|
||||||
|
const userRoleRepo = manager.getRepository(ServerUserRoleEntity);
|
||||||
|
const channelPermissionRepo = manager.getRepository(ServerChannelPermissionEntity);
|
||||||
|
const tags = normalizeServerTags(options.tags);
|
||||||
|
const channels = normalizeServerChannels(options.channels);
|
||||||
|
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
|
||||||
|
|
||||||
|
await tagRepo.delete({ serverId });
|
||||||
|
await channelRepo.delete({ serverId });
|
||||||
|
|
||||||
|
if (options.roles !== undefined) {
|
||||||
|
await roleRepo.delete({ serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.roleAssignments !== undefined) {
|
||||||
|
await userRoleRepo.delete({ serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.channelPermissions !== undefined) {
|
||||||
|
await channelPermissionRepo.delete({ serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length > 0) {
|
||||||
|
await tagRepo.insert(
|
||||||
|
tags.map((tag, position) => ({
|
||||||
|
serverId,
|
||||||
|
position,
|
||||||
|
value: tag
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channels.length > 0) {
|
||||||
|
await channelRepo.insert(
|
||||||
|
channels.map((channel) => ({
|
||||||
|
serverId,
|
||||||
|
channelId: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
type: channel.type,
|
||||||
|
position: channel.position
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.roles !== undefined && roles.length > 0) {
|
||||||
|
await roleRepo.insert(
|
||||||
|
roles.map((role) => ({
|
||||||
|
serverId,
|
||||||
|
roleId: role.id,
|
||||||
|
name: role.name,
|
||||||
|
color: role.color ?? null,
|
||||||
|
position: role.position,
|
||||||
|
isSystem: role.isSystem ? 1 : 0,
|
||||||
|
manageServer: normalizePermissionState(role.permissions?.manageServer),
|
||||||
|
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
|
||||||
|
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
|
||||||
|
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
|
||||||
|
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
|
||||||
|
banMembers: normalizePermissionState(role.permissions?.banMembers),
|
||||||
|
manageBans: normalizePermissionState(role.permissions?.manageBans),
|
||||||
|
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
|
||||||
|
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
|
||||||
|
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
|
||||||
|
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.roleAssignments !== undefined) {
|
||||||
|
const roleAssignments = normalizeServerRoleAssignments(options.roleAssignments, roles.length > 0 ? roles : normalizeServerRoles([]));
|
||||||
|
const rows = roleAssignments.flatMap((assignment) =>
|
||||||
|
assignment.roleIds.map((roleId) => ({
|
||||||
|
serverId,
|
||||||
|
userId: assignment.userId,
|
||||||
|
roleId,
|
||||||
|
oderId: assignment.oderId ?? null
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await userRoleRepo.insert(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.channelPermissions !== undefined) {
|
||||||
|
const channelPermissions = normalizeServerChannelPermissions(
|
||||||
|
options.channelPermissions,
|
||||||
|
roles.length > 0 ? roles : normalizeServerRoles([])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (channelPermissions.length > 0) {
|
||||||
|
await channelPermissionRepo.insert(
|
||||||
|
channelPermissions.map((channelPermission) => ({
|
||||||
|
serverId,
|
||||||
|
channelId: channelPermission.channelId,
|
||||||
|
targetType: channelPermission.targetType,
|
||||||
|
targetId: channelPermission.targetId,
|
||||||
|
permission: channelPermission.permission,
|
||||||
|
value: channelPermission.value
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadServerRelationsMap(
|
||||||
|
dataSource: DataSource,
|
||||||
|
serverIds: readonly string[]
|
||||||
|
): Promise<Map<string, ServerRelationRecord>> {
|
||||||
|
const groupedRelations = new Map<string, ServerRelationRecord>();
|
||||||
|
|
||||||
|
if (serverIds.length === 0) {
|
||||||
|
return groupedRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
tagRows,
|
||||||
|
channelRows,
|
||||||
|
roleRows,
|
||||||
|
userRoleRows,
|
||||||
|
channelPermissionRows
|
||||||
|
] = await Promise.all([
|
||||||
|
dataSource.getRepository(ServerTagEntity).find({
|
||||||
|
where: { serverId: In([...serverIds]) }
|
||||||
|
}),
|
||||||
|
dataSource.getRepository(ServerChannelEntity).find({
|
||||||
|
where: { serverId: In([...serverIds]) }
|
||||||
|
}),
|
||||||
|
dataSource.getRepository(ServerRoleEntity).find({
|
||||||
|
where: { serverId: In([...serverIds]) }
|
||||||
|
}),
|
||||||
|
dataSource.getRepository(ServerUserRoleEntity).find({
|
||||||
|
where: { serverId: In([...serverIds]) }
|
||||||
|
}),
|
||||||
|
dataSource.getRepository(ServerChannelPermissionEntity).find({
|
||||||
|
where: { serverId: In([...serverIds]) }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
groupedRelations.set(serverId, {
|
||||||
|
tags: [],
|
||||||
|
channels: [],
|
||||||
|
roles: [],
|
||||||
|
roleAssignments: [],
|
||||||
|
channelPermissions: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of tagRows) {
|
||||||
|
groupedRelations.get(row.serverId)?.tags.push(row.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of channelRows) {
|
||||||
|
groupedRelations.get(row.serverId)?.channels.push({
|
||||||
|
id: row.channelId,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
position: row.position
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of roleRows) {
|
||||||
|
groupedRelations.get(row.serverId)?.roles.push({
|
||||||
|
id: row.roleId,
|
||||||
|
name: row.name,
|
||||||
|
color: row.color ?? undefined,
|
||||||
|
position: row.position,
|
||||||
|
isSystem: !!row.isSystem,
|
||||||
|
permissions: normalizePermissionMatrix({
|
||||||
|
manageServer: row.manageServer,
|
||||||
|
manageRoles: row.manageRoles,
|
||||||
|
manageChannels: row.manageChannels,
|
||||||
|
manageIcon: row.manageIcon,
|
||||||
|
kickMembers: row.kickMembers,
|
||||||
|
banMembers: row.banMembers,
|
||||||
|
manageBans: row.manageBans,
|
||||||
|
deleteMessages: row.deleteMessages,
|
||||||
|
joinVoice: row.joinVoice,
|
||||||
|
shareScreen: row.shareScreen,
|
||||||
|
uploadFiles: row.uploadFiles
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of userRoleRows) {
|
||||||
|
const relation = groupedRelations.get(row.serverId);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = relation.roleAssignments.find((assignment) => assignment.userId === row.userId || assignment.oderId === row.oderId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
relation.roleAssignments.push({
|
||||||
|
userId: row.userId,
|
||||||
|
oderId: row.oderId ?? undefined,
|
||||||
|
roleIds: [row.roleId]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of channelPermissionRows) {
|
||||||
|
groupedRelations.get(row.serverId)?.channelPermissions.push({
|
||||||
|
channelId: row.channelId,
|
||||||
|
targetType: row.targetType,
|
||||||
|
targetId: row.targetId,
|
||||||
|
permission: row.permission as ServerPermissionKeyPayload,
|
||||||
|
value: normalizePermissionState(row.value)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [serverId, relation] of groupedRelations) {
|
||||||
|
relation.tags = tagRows
|
||||||
|
.filter((row) => row.serverId === serverId)
|
||||||
|
.sort((firstTag, secondTag) => firstTag.position - secondTag.position)
|
||||||
|
.map((row) => row.value);
|
||||||
|
|
||||||
|
relation.channels.sort(
|
||||||
|
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
relation.roles.sort(compareRoles);
|
||||||
|
relation.roleAssignments.sort(compareAssignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationRecordToServerPayload(
|
||||||
|
row: Pick<ServerPayload, 'slowModeInterval'>,
|
||||||
|
relations: ServerRelationRecord
|
||||||
|
): Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'> {
|
||||||
|
return {
|
||||||
|
tags: relations.tags,
|
||||||
|
channels: relations.channels,
|
||||||
|
roles: relations.roles,
|
||||||
|
roleAssignments: relations.roleAssignments,
|
||||||
|
channelPermissions: relations.channelPermissions,
|
||||||
|
slowModeInterval: row.slowModeInterval ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,53 @@ export interface AuthUserPayload {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServerChannelType = 'text' | 'voice';
|
||||||
|
|
||||||
|
export interface ServerChannelPayload {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ServerChannelType;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
export type ServerPermissionKeyPayload =
|
||||||
|
| 'manageServer'
|
||||||
|
| 'manageRoles'
|
||||||
|
| 'manageChannels'
|
||||||
|
| 'manageIcon'
|
||||||
|
| 'kickMembers'
|
||||||
|
| 'banMembers'
|
||||||
|
| 'manageBans'
|
||||||
|
| 'deleteMessages'
|
||||||
|
| 'joinVoice'
|
||||||
|
| 'shareScreen'
|
||||||
|
| 'uploadFiles';
|
||||||
|
|
||||||
|
export interface AccessRolePayload {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
position: number;
|
||||||
|
isSystem?: boolean;
|
||||||
|
permissions?: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleAssignmentPayload {
|
||||||
|
userId: string;
|
||||||
|
oderId?: string;
|
||||||
|
roleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelPermissionPayload {
|
||||||
|
channelId: string;
|
||||||
|
targetType: 'role' | 'user';
|
||||||
|
targetId: string;
|
||||||
|
permission: ServerPermissionKeyPayload;
|
||||||
|
value: PermissionStatePayload;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerPayload {
|
export interface ServerPayload {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -39,7 +86,12 @@ export interface ServerPayload {
|
|||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
currentUsers: number;
|
currentUsers: number;
|
||||||
|
slowModeInterval?: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
channels: ServerChannelPayload[];
|
||||||
|
roles?: AccessRolePayload[];
|
||||||
|
roleAssignments?: RoleAssignmentPayload[];
|
||||||
|
channelPermissions?: ChannelPermissionPayload[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { DataSource } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
|
ServerTagEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
@@ -54,6 +59,11 @@ export async function initDatabase(): Promise<void> {
|
|||||||
entities: [
|
entities: [
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
|
ServerTagEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
|
|||||||
23
server/src/entities/ServerChannelEntity.ts
Normal file
23
server/src/entities/ServerChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_channels')
|
||||||
|
export class ServerChannelEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
channelId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
type!: 'text' | 'voice';
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
position!: number;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_channel_permissions')
|
||||||
|
export class ServerChannelPermissionEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
channelId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
targetType!: 'role' | 'user';
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
targetId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
permission!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
value!: 'allow' | 'deny' | 'inherit';
|
||||||
|
}
|
||||||
@@ -33,8 +33,8 @@ export class ServerEntity {
|
|||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
currentUsers!: number;
|
currentUsers!: number;
|
||||||
|
|
||||||
@Column('text', { default: '[]' })
|
@Column('integer', { default: 0 })
|
||||||
tags!: string;
|
slowModeInterval!: number;
|
||||||
|
|
||||||
@Column('integer')
|
@Column('integer')
|
||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
|
|||||||
59
server/src/entities/ServerRoleEntity.ts
Normal file
59
server/src/entities/ServerRoleEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_roles')
|
||||||
|
export class ServerRoleEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roleId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
color!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
position!: number;
|
||||||
|
|
||||||
|
@Column('integer', { default: 0 })
|
||||||
|
isSystem!: number;
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageServer!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageRoles!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageChannels!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageIcon!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
kickMembers!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
banMembers!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
manageBans!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
deleteMessages!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
joinVoice!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
shareScreen!: 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
|
@Column('text', { default: 'inherit' })
|
||||||
|
uploadFiles!: 'allow' | 'deny' | 'inherit';
|
||||||
|
}
|
||||||
17
server/src/entities/ServerTagEntity.ts
Normal file
17
server/src/entities/ServerTagEntity.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_tags')
|
||||||
|
export class ServerTagEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('integer')
|
||||||
|
position!: number;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
value!: string;
|
||||||
|
}
|
||||||
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_user_roles')
|
||||||
|
export class ServerUserRoleEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
roleId!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
oderId!: string | null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
export { AuthUserEntity } from './AuthUserEntity';
|
export { AuthUserEntity } from './AuthUserEntity';
|
||||||
export { ServerEntity } from './ServerEntity';
|
export { ServerEntity } from './ServerEntity';
|
||||||
|
export { ServerTagEntity } from './ServerTagEntity';
|
||||||
|
export { ServerChannelEntity } from './ServerChannelEntity';
|
||||||
|
export { ServerRoleEntity } from './ServerRoleEntity';
|
||||||
|
export { ServerUserRoleEntity } from './ServerUserRoleEntity';
|
||||||
|
export { ServerChannelPermissionEntity } from './ServerChannelPermissionEntity';
|
||||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
|
|||||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
"tags" TEXT NOT NULL DEFAULT '[]',
|
"tags" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"channels" TEXT NOT NULL DEFAULT '[]',
|
||||||
"createdAt" INTEGER NOT NULL,
|
"createdAt" INTEGER NOT NULL,
|
||||||
"lastSeen" INTEGER NOT NULL
|
"lastSeen" INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
|
|||||||
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class ServerChannels1000000000002 implements MigrationInterface {
|
||||||
|
name = 'ServerChannels1000000000002';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
interface LegacyServerRow {
|
||||||
|
id: string;
|
||||||
|
channels: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyServerChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'text' | 'voice';
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw || '[]');
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
|
||||||
|
.map((channel, index) => {
|
||||||
|
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||||
|
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||||
|
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||||
|
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||||
|
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
|
||||||
|
|
||||||
|
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIds.add(id);
|
||||||
|
seenNames.add(nameKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
position
|
||||||
|
} satisfies LegacyServerChannel;
|
||||||
|
})
|
||||||
|
.filter((channel): channel is LegacyServerChannel => !!channel);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
|
||||||
|
const hasTextGeneral = channels.some(
|
||||||
|
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
|
||||||
|
);
|
||||||
|
const hasVoiceAfk = channels.some(
|
||||||
|
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
|
||||||
|
);
|
||||||
|
const hasVoiceGeneral = channels.some(
|
||||||
|
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
|
||||||
|
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textChannels = channels.filter((channel) => channel.type === 'text');
|
||||||
|
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
|
||||||
|
const repairedVoiceChannels = [
|
||||||
|
{
|
||||||
|
id: 'vc-general',
|
||||||
|
name: 'General',
|
||||||
|
type: 'voice' as const,
|
||||||
|
position: 0
|
||||||
|
},
|
||||||
|
...voiceChannels
|
||||||
|
].map((channel, index) => ({
|
||||||
|
...channel,
|
||||||
|
position: index
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...textChannels,
|
||||||
|
...repairedVoiceChannels
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
|
||||||
|
name = 'RepairLegacyVoiceChannels1000000000003';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const channels = normalizeLegacyChannels(row.channels);
|
||||||
|
const repaired = repairLegacyVoiceChannels(channels);
|
||||||
|
|
||||||
|
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
|
||||||
|
[JSON.stringify(repaired), row.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Forward-only data repair migration.
|
||||||
|
}
|
||||||
|
}
|
||||||
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
type LegacyServerRow = {
|
||||||
|
id: string;
|
||||||
|
tags: string | null;
|
||||||
|
channels: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LegacyServerChannel = {
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
position?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArray<T>(raw: string | null): T[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw || '[]');
|
||||||
|
|
||||||
|
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelName(name: string): string {
|
||||||
|
return name.trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelNameKey(type: 'text' | 'voice', name: string): string {
|
||||||
|
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerTags(raw: string | null): string[] {
|
||||||
|
return parseArray<unknown>(raw).filter((tag): tag is string => typeof tag === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerChannels(raw: string | null) {
|
||||||
|
const channels = parseArray<LegacyServerChannel>(raw);
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
|
||||||
|
return channels.flatMap((channel, index) => {
|
||||||
|
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||||
|
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||||
|
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||||
|
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||||
|
const nameKey = type ? channelNameKey(type, name) : '';
|
||||||
|
|
||||||
|
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIds.add(id);
|
||||||
|
seenNames.add(nameKey);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
channelId: id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
position
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NormalizeServerArrays1000000000004 implements MigrationInterface {
|
||||||
|
name = 'NormalizeServerArrays1000000000004';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_tags" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
PRIMARY KEY ("serverId", "position")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_tags_serverId" ON "server_tags" ("serverId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_channels" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("serverId", "channelId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channels_serverId" ON "server_channels" ("serverId")`);
|
||||||
|
|
||||||
|
const rows = await queryRunner.query(`SELECT "id", "tags", "channels" FROM "servers"`) as LegacyServerRow[];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const [position, tag] of normalizeServerTags(row.tags).entries()) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT OR REPLACE INTO "server_tags" ("serverId", "position", "value") VALUES (?, ?, ?)`,
|
||||||
|
[row.id, position, tag]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const channel of normalizeServerChannels(row.channels)) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT OR REPLACE INTO "server_channels" ("serverId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "servers_next" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"ownerPublicKey" TEXT NOT NULL,
|
||||||
|
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" INTEGER NOT NULL,
|
||||||
|
"lastSeen" INTEGER NOT NULL,
|
||||||
|
"passwordHash" TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash")
|
||||||
|
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash"
|
||||||
|
FROM "servers"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE "servers"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
type LegacyServerRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
ownerId: string;
|
||||||
|
ownerPublicKey: string;
|
||||||
|
passwordHash: string | null;
|
||||||
|
isPrivate: number;
|
||||||
|
maxUsers: number;
|
||||||
|
currentUsers: number;
|
||||||
|
createdAt: number;
|
||||||
|
lastSeen: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_ROLE_IDS = {
|
||||||
|
everyone: 'system-everyone',
|
||||||
|
moderator: 'system-moderator',
|
||||||
|
admin: 'system-admin'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function buildDefaultServerRoles() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
roleId: SYSTEM_ROLE_IDS.everyone,
|
||||||
|
name: '@everyone',
|
||||||
|
color: '#6b7280',
|
||||||
|
position: 0,
|
||||||
|
isSystem: 1,
|
||||||
|
manageServer: 'inherit',
|
||||||
|
manageRoles: 'inherit',
|
||||||
|
manageChannels: 'inherit',
|
||||||
|
manageIcon: 'inherit',
|
||||||
|
kickMembers: 'inherit',
|
||||||
|
banMembers: 'inherit',
|
||||||
|
manageBans: 'inherit',
|
||||||
|
deleteMessages: 'inherit',
|
||||||
|
joinVoice: 'allow',
|
||||||
|
shareScreen: 'allow',
|
||||||
|
uploadFiles: 'allow'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roleId: SYSTEM_ROLE_IDS.moderator,
|
||||||
|
name: 'Moderator',
|
||||||
|
color: '#10b981',
|
||||||
|
position: 200,
|
||||||
|
isSystem: 1,
|
||||||
|
manageServer: 'inherit',
|
||||||
|
manageRoles: 'inherit',
|
||||||
|
manageChannels: 'inherit',
|
||||||
|
manageIcon: 'inherit',
|
||||||
|
kickMembers: 'allow',
|
||||||
|
banMembers: 'inherit',
|
||||||
|
manageBans: 'inherit',
|
||||||
|
deleteMessages: 'allow',
|
||||||
|
joinVoice: 'inherit',
|
||||||
|
shareScreen: 'inherit',
|
||||||
|
uploadFiles: 'inherit'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roleId: SYSTEM_ROLE_IDS.admin,
|
||||||
|
name: 'Admin',
|
||||||
|
color: '#60a5fa',
|
||||||
|
position: 300,
|
||||||
|
isSystem: 1,
|
||||||
|
manageServer: 'inherit',
|
||||||
|
manageRoles: 'inherit',
|
||||||
|
manageChannels: 'allow',
|
||||||
|
manageIcon: 'allow',
|
||||||
|
kickMembers: 'allow',
|
||||||
|
banMembers: 'allow',
|
||||||
|
manageBans: 'allow',
|
||||||
|
deleteMessages: 'allow',
|
||||||
|
joinVoice: 'inherit',
|
||||||
|
shareScreen: 'inherit',
|
||||||
|
uploadFiles: 'inherit'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerRoleAccessControl1000000000005 implements MigrationInterface {
|
||||||
|
name = 'ServerRoleAccessControl1000000000005';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_roles" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"color" TEXT,
|
||||||
|
"position" INTEGER NOT NULL,
|
||||||
|
"isSystem" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
|
||||||
|
PRIMARY KEY ("serverId", "roleId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_roles_serverId" ON "server_roles" ("serverId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_user_roles" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"oderId" TEXT,
|
||||||
|
PRIMARY KEY ("serverId", "userId", "roleId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_user_roles_serverId" ON "server_user_roles" ("serverId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_channel_permissions" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"targetType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT NOT NULL,
|
||||||
|
"permission" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
PRIMARY KEY ("serverId", "channelId", "targetType", "targetId", "permission")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channel_permissions_serverId" ON "server_channel_permissions" ("serverId")`);
|
||||||
|
|
||||||
|
const servers = await queryRunner.query(`
|
||||||
|
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen"
|
||||||
|
FROM "servers"
|
||||||
|
`) as LegacyServerRow[];
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
for (const role of buildDefaultServerRoles()) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT OR REPLACE INTO "server_roles" ("serverId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
server.id,
|
||||||
|
role.roleId,
|
||||||
|
role.name,
|
||||||
|
role.color,
|
||||||
|
role.position,
|
||||||
|
role.isSystem,
|
||||||
|
role.manageServer,
|
||||||
|
role.manageRoles,
|
||||||
|
role.manageChannels,
|
||||||
|
role.manageIcon,
|
||||||
|
role.kickMembers,
|
||||||
|
role.banMembers,
|
||||||
|
role.manageBans,
|
||||||
|
role.deleteMessages,
|
||||||
|
role.joinVoice,
|
||||||
|
role.shareScreen,
|
||||||
|
role.uploadFiles
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "servers_next" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"ownerPublicKey" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" INTEGER NOT NULL,
|
||||||
|
"lastSeen" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
|
||||||
|
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", 0, "createdAt", "lastSeen"
|
||||||
|
FROM "servers"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE "servers"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_channel_permissions"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_user_roles"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_roles"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||||
|
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
||||||
|
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||||
|
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||||
|
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
ServerAccessControl1000000000001
|
ServerAccessControl1000000000001,
|
||||||
|
ServerChannels1000000000002,
|
||||||
|
RepairLegacyVoiceChannels1000000000003,
|
||||||
|
NormalizeServerArrays1000000000004,
|
||||||
|
ServerRoleAccessControl1000000000005
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Express } from 'express';
|
import { Express } from 'express';
|
||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
|
import linkMetadataRouter from './link-metadata';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
@@ -10,6 +11,7 @@ import { invitesApiRouter, invitePageRouter } from './invites';
|
|||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
|
app.use('/api', linkMetadataRouter);
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getUserById } from '../cqrs';
|
import { getUserById } from '../cqrs';
|
||||||
import { rowToServer } from '../cqrs/mappers';
|
|
||||||
import { ServerPayload } from '../cqrs/types';
|
import { ServerPayload } from '../cqrs/types';
|
||||||
import { getActiveServerInvite } from '../services/server-access.service';
|
import { getActiveServerInvite } from '../services/server-access.service';
|
||||||
import {
|
import {
|
||||||
@@ -283,7 +282,7 @@ invitesApiRouter.get('/:id', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = rowToServer(bundle.server);
|
const server = bundle.server;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: bundle.invite.id,
|
id: bundle.invite.id,
|
||||||
@@ -315,7 +314,7 @@ invitePageRouter.get('/:id', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = rowToServer(bundle.server);
|
const server = bundle.server;
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
|
|
||||||
res.send(renderInvitePage({
|
res.send(renderInvitePage({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
updateJoinRequestStatus
|
updateJoinRequestStatus
|
||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import { notifyUser } from '../websocket/broadcast';
|
import { notifyUser } from '../websocket/broadcast';
|
||||||
|
import { resolveServerPermission } from '../services/server-permissions.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
|
|
||||||
const server = await getServerById(request.serverId);
|
const server = await getServerById(request.serverId);
|
||||||
|
|
||||||
if (!server || server.ownerId !== ownerId)
|
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
|
||||||
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);
|
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);
|
||||||
|
|||||||
292
server/src/routes/link-metadata.ts
Normal file
292
server/src/routes/link-metadata.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getLinkPreviewConfig } from '../config/variables';
|
||||||
|
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
|
const MAX_HTML_BYTES = 512 * 1024;
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
const MAX_FIELD_LENGTH = 512;
|
||||||
|
|
||||||
|
interface CachedMetadata {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
siteName?: string;
|
||||||
|
failed?: boolean;
|
||||||
|
cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataCache = new Map<string, CachedMetadata>();
|
||||||
|
|
||||||
|
let cacheByteEstimate = 0;
|
||||||
|
|
||||||
|
function estimateEntryBytes(key: string, entry: CachedMetadata): number {
|
||||||
|
let bytes = key.length * 2;
|
||||||
|
|
||||||
|
if (entry.title)
|
||||||
|
bytes += entry.title.length * 2;
|
||||||
|
|
||||||
|
if (entry.description)
|
||||||
|
bytes += entry.description.length * 2;
|
||||||
|
|
||||||
|
if (entry.imageUrl)
|
||||||
|
bytes += entry.imageUrl.length * 2;
|
||||||
|
|
||||||
|
if (entry.siteName)
|
||||||
|
bytes += entry.siteName.length * 2;
|
||||||
|
|
||||||
|
return bytes + 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(key: string, entry: CachedMetadata): void {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
const maxBytes = config.maxCacheSizeMb * BYTES_PER_MB;
|
||||||
|
|
||||||
|
if (metadataCache.has(key)) {
|
||||||
|
const existing = metadataCache.get(key) as CachedMetadata;
|
||||||
|
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryBytes = estimateEntryBytes(key, entry);
|
||||||
|
|
||||||
|
while (cacheByteEstimate + entryBytes > maxBytes && metadataCache.size > 0) {
|
||||||
|
const oldest = metadataCache.keys().next().value as string;
|
||||||
|
const oldestEntry = metadataCache.get(oldest) as CachedMetadata;
|
||||||
|
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(oldest, oldestEntry);
|
||||||
|
metadataCache.delete(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataCache.set(key, entry);
|
||||||
|
cacheByteEstimate += entryBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateField(value: string | undefined): string | undefined {
|
||||||
|
if (!value)
|
||||||
|
return value;
|
||||||
|
|
||||||
|
if (value.length <= MAX_FIELD_LENGTH)
|
||||||
|
return value;
|
||||||
|
|
||||||
|
return value.slice(0, MAX_FIELD_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeImageUrl(rawUrl: string | undefined, baseUrl: string): string | undefined {
|
||||||
|
if (!rawUrl)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = new URL(rawUrl, baseUrl);
|
||||||
|
|
||||||
|
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:')
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
return resolved.href;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetaContent(html: string, patterns: RegExp[]): string | undefined {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = pattern.exec(html);
|
||||||
|
|
||||||
|
if (match?.[1])
|
||||||
|
return decodeHtmlEntities(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(///g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(html: string, url: string): CachedMetadata {
|
||||||
|
const title = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i,
|
||||||
|
/<title[^>]*>([^<]+)<\/title>/i
|
||||||
|
]);
|
||||||
|
const description = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i,
|
||||||
|
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i
|
||||||
|
]);
|
||||||
|
const rawImageUrl = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i
|
||||||
|
]);
|
||||||
|
const siteNamePatterns = [
|
||||||
|
// eslint-disable-next-line @stylistic/js/array-element-newline
|
||||||
|
/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i
|
||||||
|
];
|
||||||
|
const siteName = getMetaContent(html, siteNamePatterns);
|
||||||
|
const imageUrl = sanitizeImageUrl(rawImageUrl, url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: truncateField(title),
|
||||||
|
description: truncateField(description),
|
||||||
|
imageUrl,
|
||||||
|
siteName: truncateField(siteName),
|
||||||
|
cachedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictExpired(): void {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
|
||||||
|
if (config.cacheTtlMinutes === 0) {
|
||||||
|
cacheByteEstimate = 0;
|
||||||
|
metadataCache.clear();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlMs = config.cacheTtlMinutes * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [key, entry] of metadataCache) {
|
||||||
|
if (now - entry.cachedAt > ttlMs) {
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(key, entry);
|
||||||
|
metadataCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/link-metadata', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return res.status(403).json({ error: 'Link previews are disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = String(req.query.url || '');
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostAllowed = await resolveAndValidateHost(url);
|
||||||
|
|
||||||
|
if (!hostAllowed) {
|
||||||
|
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
evictExpired();
|
||||||
|
|
||||||
|
const cached = metadataCache.get(url);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const { cachedAt, ...metadata } = cached;
|
||||||
|
|
||||||
|
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
|
||||||
|
return res.json(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Link Metadata] Cache miss for ${url}. Fetching...`);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
const response = await safeFetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html',
|
||||||
|
'User-Agent': 'MetoYou-LinkPreview/1.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (!contentType.includes('text/html')) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const result = await reader.read();
|
||||||
|
|
||||||
|
done = result.done;
|
||||||
|
|
||||||
|
if (result.value) {
|
||||||
|
chunks.push(result.value);
|
||||||
|
totalBytes += result.value.length;
|
||||||
|
|
||||||
|
if (totalBytes > MAX_HTML_BYTES) {
|
||||||
|
reader.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
const metadata = parseMetadata(html, url);
|
||||||
|
|
||||||
|
cacheSet(url, metadata);
|
||||||
|
|
||||||
|
const { cachedAt, ...result } = metadata;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const url = String(req.query.url || '');
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
cacheSet(url, { failed: true, cachedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((err as { name?: string })?.name === 'AbortError') {
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Link metadata error:', err);
|
||||||
|
res.json({ failed: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,14 +11,20 @@ router.get('/image-proxy', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid URL' });
|
return res.status(400).json({ error: 'Invalid URL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostAllowed = await resolveAndValidateHost(url);
|
||||||
|
|
||||||
|
if (!hostAllowed) {
|
||||||
|
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
|
const response = await safeFetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return res.status(response.status).end();
|
return res.status(response?.status ?? 502).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Response, Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ServerPayload } from '../cqrs/types';
|
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
|
||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
@@ -27,15 +27,53 @@ import {
|
|||||||
buildInviteUrl,
|
buildInviteUrl,
|
||||||
getRequestOrigin
|
getRequestOrigin
|
||||||
} from './invite-utils';
|
} from './invite-utils';
|
||||||
|
import {
|
||||||
|
canManageServerUpdate,
|
||||||
|
canModerateServerMember,
|
||||||
|
resolveServerPermission
|
||||||
|
} from '../services/server-permissions.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
function normalizeRole(role: unknown): string | null {
|
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||||
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
return `${type}:${name.toLocaleLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||||
return !!role && allowedRoles.includes(role);
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const channels: ServerChannelPayload[] = [];
|
||||||
|
|
||||||
|
for (const [index, channel] of value.entries()) {
|
||||||
|
if (!channel || typeof channel !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||||
|
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||||
|
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||||
|
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||||
|
const nameKey = type ? channelNameKey(type, name) : '';
|
||||||
|
|
||||||
|
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(id);
|
||||||
|
seenNames.add(nameKey);
|
||||||
|
channels.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
position
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||||
@@ -124,7 +162,8 @@ router.post('/', async (req, res) => {
|
|||||||
isPrivate,
|
isPrivate,
|
||||||
maxUsers,
|
maxUsers,
|
||||||
password,
|
password,
|
||||||
tags
|
tags,
|
||||||
|
channels
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name || !ownerId || !ownerPublicKey)
|
if (!name || !ownerId || !ownerPublicKey)
|
||||||
@@ -143,6 +182,7 @@ router.post('/', async (req, res) => {
|
|||||||
maxUsers: maxUsers ?? 0,
|
maxUsers: maxUsers ?? 0,
|
||||||
currentUsers: 0,
|
currentUsers: 0,
|
||||||
tags: tags ?? [],
|
tags: tags ?? [],
|
||||||
|
channels: normalizeServerChannels(channels),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastSeen: Date.now()
|
lastSeen: Date.now()
|
||||||
};
|
};
|
||||||
@@ -161,27 +201,35 @@ router.put('/:id', async (req, res) => {
|
|||||||
password,
|
password,
|
||||||
hasPassword: _ignoredHasPassword,
|
hasPassword: _ignoredHasPassword,
|
||||||
passwordHash: _ignoredPasswordHash,
|
passwordHash: _ignoredPasswordHash,
|
||||||
|
channels,
|
||||||
...updates
|
...updates
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||||
const normalizedRole = normalizeRole(actingRole);
|
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (
|
if (!authenticatedOwnerId) {
|
||||||
existing.ownerId !== authenticatedOwnerId &&
|
return res.status(400).json({ error: 'Missing currentOwnerId' });
|
||||||
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
}
|
||||||
) {
|
|
||||||
|
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
|
||||||
|
...updates,
|
||||||
|
channels,
|
||||||
|
password,
|
||||||
|
actingRole
|
||||||
|
})) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||||
|
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
|
||||||
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||||
const server: ServerPayload = {
|
const server: ServerPayload = {
|
||||||
...existing,
|
...existing,
|
||||||
...updates,
|
...updates,
|
||||||
|
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
|
||||||
hasPassword: !!nextPasswordHash,
|
hasPassword: !!nextPasswordHash,
|
||||||
passwordHash: nextPasswordHash,
|
passwordHash: nextPasswordHash,
|
||||||
lastSeen: Date.now()
|
lastSeen: Date.now()
|
||||||
@@ -249,7 +297,7 @@ router.post('/:id/invites', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/:id/moderation/kick', async (req, res) => {
|
router.post('/:id/moderation/kick', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, actorRole, targetUserId } = req.body;
|
const { actorUserId, targetUserId } = req.body;
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -260,14 +308,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +319,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/:id/moderation/ban', async (req, res) => {
|
router.post('/:id/moderation/ban', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -289,14 +330,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,21 +349,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/:id/moderation/unban', async (req, res) => {
|
router.post('/:id/moderation/unban', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
const { actorUserId, banId, targetUserId } = req.body;
|
||||||
const server = await getServerById(serverId);
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
119
server/src/routes/ssrf-guard.ts
Normal file
119
server/src/routes/ssrf-guard.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { lookup } from 'dns/promises';
|
||||||
|
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
|
||||||
|
function isPrivateIp(ip: string): boolean {
|
||||||
|
if (
|
||||||
|
ip === '127.0.0.1' ||
|
||||||
|
ip === '::1' ||
|
||||||
|
ip === '0.0.0.0' ||
|
||||||
|
ip === '::'
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 10.x.x.x
|
||||||
|
if (ip.startsWith('10.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 172.16.0.0 - 172.31.255.255
|
||||||
|
if (ip.startsWith('172.')) {
|
||||||
|
const second = parseInt(ip.split('.')[1], 10);
|
||||||
|
|
||||||
|
if (second >= 16 && second <= 31)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 192.168.x.x
|
||||||
|
if (ip.startsWith('192.168.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 169.254.x.x (link-local, AWS metadata)
|
||||||
|
if (ip.startsWith('169.254.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// IPv6 private ranges (fc00::/7, fe80::/10)
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
|
||||||
|
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAndValidateHost(url: string): Promise<boolean> {
|
||||||
|
let hostname: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hostname = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block obvious private hostnames
|
||||||
|
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// If hostname is already an IP literal, check it directly
|
||||||
|
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
|
||||||
|
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { address } = await lookup(hostname);
|
||||||
|
|
||||||
|
return !isPrivateIp(address);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeFetchOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a URL while following redirects safely, validating each
|
||||||
|
* hop against SSRF (private/reserved IPs, blocked hostnames).
|
||||||
|
*
|
||||||
|
* The caller must validate the initial URL with `resolveAndValidateHost`
|
||||||
|
* before calling this function.
|
||||||
|
*/
|
||||||
|
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
|
||||||
|
let currentUrl = url;
|
||||||
|
let response: Response | undefined;
|
||||||
|
|
||||||
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: options.signal,
|
||||||
|
headers: options.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400 && location) {
|
||||||
|
let nextUrl: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
nextUrl = new URL(location, currentUrl).href;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(nextUrl))
|
||||||
|
break;
|
||||||
|
|
||||||
|
const redirectAllowed = await resolveAndValidateHost(nextUrl);
|
||||||
|
|
||||||
|
if (!redirectAllowed)
|
||||||
|
break;
|
||||||
|
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ServerMembershipEntity
|
ServerMembershipEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { rowToServer } from '../cqrs/mappers';
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { loadServerRelationsMap } from '../cqrs/relations';
|
||||||
import { ServerPayload } from '../cqrs/types';
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
|
||||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||||
@@ -57,6 +58,12 @@ function getBanRepository() {
|
|||||||
return getDataSource().getRepository(ServerBanEntity);
|
return getDataSource().getRepository(ServerBanEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toServerPayload(server: ServerEntity): Promise<ServerPayload> {
|
||||||
|
const relationsByServerId = await loadServerRelationsMap(getDataSource(), [server.id]);
|
||||||
|
|
||||||
|
return rowToServer(server, relationsByServerId.get(server.id));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePassword(password?: string | null): string | null {
|
function normalizePassword(password?: string | null): string | null {
|
||||||
const normalized = password?.trim() ?? '';
|
const normalized = password?.trim() ?? '';
|
||||||
|
|
||||||
@@ -194,7 +201,7 @@ export async function createServerInvite(
|
|||||||
|
|
||||||
export async function getActiveServerInvite(
|
export async function getActiveServerInvite(
|
||||||
inviteId: string
|
inviteId: string
|
||||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
): Promise<{ invite: ServerInviteEntity; server: ServerPayload } | null> {
|
||||||
await pruneExpiredServerAccessArtifacts();
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||||
@@ -214,7 +221,10 @@ export async function getActiveServerInvite(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { invite, server };
|
return {
|
||||||
|
invite,
|
||||||
|
server: await toServerPayload(server)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinServerWithAccess(options: {
|
export async function joinServerWithAccess(options: {
|
||||||
@@ -242,7 +252,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: !!existingMembership,
|
joinedBefore: !!existingMembership,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'membership'
|
via: 'membership'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -260,7 +270,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: !!existingMembership,
|
joinedBefore: !!existingMembership,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'invite'
|
via: 'invite'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -272,7 +282,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: true,
|
joinedBefore: true,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'membership'
|
via: 'membership'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -288,7 +298,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: false,
|
joinedBefore: false,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'password'
|
via: 'password'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -301,7 +311,7 @@ export async function joinServerWithAccess(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
joinedBefore: false,
|
joinedBefore: false,
|
||||||
server: rowToServer(server),
|
server: await toServerPayload(server),
|
||||||
via: 'public'
|
via: 'public'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
191
server/src/services/server-permissions.service.ts
Normal file
191
server/src/services/server-permissions.service.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import type {
|
||||||
|
AccessRolePayload,
|
||||||
|
PermissionStatePayload,
|
||||||
|
RoleAssignmentPayload,
|
||||||
|
ServerPayload,
|
||||||
|
ServerPermissionKeyPayload
|
||||||
|
} from '../cqrs/types';
|
||||||
|
import { normalizeServerRoleAssignments, normalizeServerRoles } from '../cqrs/relations';
|
||||||
|
|
||||||
|
const SYSTEM_ROLE_IDS = {
|
||||||
|
everyone: 'system-everyone'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface ServerIdentity {
|
||||||
|
userId: string;
|
||||||
|
oderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerRoles(server: Pick<ServerPayload, 'roles'>): AccessRolePayload[] {
|
||||||
|
return normalizeServerRoles(server.roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerAssignments(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>): RoleAssignmentPayload[] {
|
||||||
|
return normalizeServerRoleAssignments(server.roleAssignments, getServerRoles(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesIdentity(identity: ServerIdentity, assignment: RoleAssignmentPayload): boolean {
|
||||||
|
return assignment.userId === identity.userId
|
||||||
|
|| assignment.oderId === identity.userId
|
||||||
|
|| (!!identity.oderId && (assignment.userId === identity.oderId || assignment.oderId === identity.oderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAssignedRoleIds(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>, identity: ServerIdentity): string[] {
|
||||||
|
const assignment = getServerAssignments(server).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
|
||||||
|
|
||||||
|
return assignment?.roleIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRolePosition(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
|
||||||
|
if (firstRole.position !== secondRole.position) {
|
||||||
|
return firstRole.position - secondRole.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstRole.name.localeCompare(secondRole.name, undefined, { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRolePermissionState(
|
||||||
|
roles: readonly AccessRolePayload[],
|
||||||
|
assignedRoleIds: readonly string[],
|
||||||
|
permission: ServerPermissionKeyPayload
|
||||||
|
): PermissionStatePayload {
|
||||||
|
const roleLookup = new Map(roles.map((role) => [role.id, role]));
|
||||||
|
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoleIds.map((roleId) => roleLookup.get(roleId))]
|
||||||
|
.filter((role): role is AccessRolePayload => !!role)
|
||||||
|
.sort(compareRolePosition);
|
||||||
|
|
||||||
|
let state: PermissionStatePayload = 'inherit';
|
||||||
|
|
||||||
|
for (const role of effectiveRoles) {
|
||||||
|
const nextState = role.permissions?.[permission] ?? 'inherit';
|
||||||
|
|
||||||
|
if (nextState !== 'inherit') {
|
||||||
|
state = nextState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHighestRole(
|
||||||
|
server: Pick<ServerPayload, 'roleAssignments' | 'roles'>,
|
||||||
|
identity: ServerIdentity
|
||||||
|
): AccessRolePayload | null {
|
||||||
|
const roles = getServerRoles(server);
|
||||||
|
const assignedRoleIds = resolveAssignedRoleIds(server, identity);
|
||||||
|
const roleLookup = new Map(roles.map((role) => [role.id, role]));
|
||||||
|
const assignedRoles = assignedRoleIds
|
||||||
|
.map((roleId) => roleLookup.get(roleId))
|
||||||
|
.filter((role): role is AccessRolePayload => !!role)
|
||||||
|
.sort((firstRole, secondRole) => compareRolePosition(secondRole, firstRole));
|
||||||
|
|
||||||
|
return assignedRoles[0] ?? roleLookup.get(SYSTEM_ROLE_IDS.everyone) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isServerOwner(server: Pick<ServerPayload, 'ownerId'>, actorUserId: string): boolean {
|
||||||
|
return server.ownerId === actorUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveServerPermission(
|
||||||
|
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||||
|
actorUserId: string,
|
||||||
|
permission: ServerPermissionKeyPayload,
|
||||||
|
actorOderId?: string
|
||||||
|
): boolean {
|
||||||
|
if (isServerOwner(server, actorUserId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = getServerRoles(server);
|
||||||
|
const assignedRoleIds = resolveAssignedRoleIds(server, {
|
||||||
|
userId: actorUserId,
|
||||||
|
oderId: actorOderId
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolveRolePermissionState(roles, assignedRoleIds, permission) === 'allow';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageServerUpdate(
|
||||||
|
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||||
|
actorUserId: string,
|
||||||
|
updates: Record<string, unknown>,
|
||||||
|
actorOderId?: string
|
||||||
|
): boolean {
|
||||||
|
if (isServerOwner(server, actorUserId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof updates['ownerId'] === 'string' || typeof updates['ownerPublicKey'] === 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredPermissions = new Set<ServerPermissionKeyPayload>();
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(updates['roles'])
|
||||||
|
|| Array.isArray(updates['roleAssignments'])
|
||||||
|
|| Array.isArray(updates['channelPermissions'])
|
||||||
|
) {
|
||||||
|
requiredPermissions.add('manageRoles');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(updates['channels'])) {
|
||||||
|
requiredPermissions.add('manageChannels');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof updates['icon'] === 'string') {
|
||||||
|
requiredPermissions.add('manageIcon');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof updates['name'] === 'string'
|
||||||
|
|| typeof updates['description'] === 'string'
|
||||||
|
|| typeof updates['isPrivate'] === 'boolean'
|
||||||
|
|| typeof updates['maxUsers'] === 'number'
|
||||||
|
|| typeof updates['password'] === 'string'
|
||||||
|
|| typeof updates['passwordHash'] === 'string'
|
||||||
|
|| typeof updates['slowModeInterval'] === 'number'
|
||||||
|
) {
|
||||||
|
requiredPermissions.add('manageServer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(requiredPermissions).every((permission) =>
|
||||||
|
resolveServerPermission(server, actorUserId, permission, actorOderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canModerateServerMember(
|
||||||
|
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||||
|
actorUserId: string,
|
||||||
|
targetUserId: string,
|
||||||
|
permission: 'kickMembers' | 'banMembers' | 'manageBans',
|
||||||
|
actorOderId?: string,
|
||||||
|
targetOderId?: string
|
||||||
|
): boolean {
|
||||||
|
if (!actorUserId || !targetUserId || actorUserId === targetUserId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, targetUserId) && !isServerOwner(server, actorUserId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, actorUserId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolveServerPermission(server, actorUserId, permission, actorOderId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorRole = resolveHighestRole(server, {
|
||||||
|
userId: actorUserId,
|
||||||
|
oderId: actorOderId
|
||||||
|
});
|
||||||
|
const targetRole = resolveHighestRole(server, {
|
||||||
|
userId: targetUserId,
|
||||||
|
oderId: targetOderId
|
||||||
|
});
|
||||||
|
|
||||||
|
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -24,6 +26,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
|
||||||
|
const usersByOderId = new Map<string, ConnectedUser>();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user) => {
|
||||||
|
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersByOderId.set(user.oderId, user);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(usersByOderId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
|
||||||
|
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
|
||||||
|
connectionId !== excludeConnectionId
|
||||||
|
&& user.oderId === oderId
|
||||||
|
&& user.serverIds.has(serverId)
|
||||||
|
&& user.ws.readyState === WebSocket.OPEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
|
||||||
|
const serverIds = new Set<string>();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user, connectionId) => {
|
||||||
|
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.serverIds.forEach((serverId) => serverIds.add(serverId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(serverIds);
|
||||||
|
}
|
||||||
|
|
||||||
export function notifyUser(oderId: string, message: WsMessage): void {
|
export function notifyUser(oderId: string, message: WsMessage): void {
|
||||||
const user = findUserByOderId(oderId);
|
const user = findUserByOderId(oderId);
|
||||||
|
|
||||||
@@ -33,5 +72,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findUserByOderId(oderId: string) {
|
export function findUserByOderId(oderId: string) {
|
||||||
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId);
|
let match: ConnectedUser | undefined;
|
||||||
|
|
||||||
|
connectedUsers.forEach((user) => {
|
||||||
|
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
|
||||||
|
match = user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
import {
|
||||||
|
broadcastToServer,
|
||||||
|
findUserByOderId,
|
||||||
|
getServerIdsForOderId,
|
||||||
|
getUniqueUsersInServer,
|
||||||
|
isOderIdConnectedToServer
|
||||||
|
} from './broadcast';
|
||||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
@@ -8,24 +14,59 @@ interface WsMessage {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessageId(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
if (!normalized || normalized === 'undefined' || normalized === 'null') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = Array.from(connectedUsers.values())
|
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
user.oderId = String(message['oderId'] || connectionId);
|
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
|
||||||
|
// Close stale connections from the same identity so offer routing
|
||||||
|
// always targets the freshest socket (e.g. after page refresh).
|
||||||
|
connectedUsers.forEach((existing, existingId) => {
|
||||||
|
if (existingId !== connectionId && existing.oderId === newOderId) {
|
||||||
|
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
existing.ws.close();
|
||||||
|
} catch { /* already closing */ }
|
||||||
|
|
||||||
|
connectedUsers.delete(existingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
user.oderId = newOderId;
|
||||||
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const sid = String(message['serverId']);
|
const sid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
if (!sid)
|
if (!sid)
|
||||||
return;
|
return;
|
||||||
@@ -42,37 +83,44 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNew = !user.serverIds.has(sid);
|
const isNewConnectionMembership = !user.serverIds.has(sid);
|
||||||
|
const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId);
|
||||||
|
|
||||||
user.serverIds.add(sid);
|
user.serverIds.add(sid);
|
||||||
user.viewedServerId = sid;
|
user.viewedServerId = sid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
console.log(
|
||||||
|
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
|
||||||
|
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
|
||||||
|
);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
|
|
||||||
if (isNew) {
|
if (isNewIdentityMembership) {
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const viewSid = String(message['serverId']);
|
const viewSid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
|
if (!viewSid)
|
||||||
|
return;
|
||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||||
|
|
||||||
sendServerUsers(user, viewSid);
|
sendServerUsers(user, viewSid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||||
|
|
||||||
if (!leaveSid)
|
if (!leaveSid)
|
||||||
return;
|
return;
|
||||||
@@ -84,17 +132,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
|
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
|
if (remainingServerIds.includes(leaveSid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToServer(leaveSid, {
|
broadcastToServer(leaveSid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: leaveSid,
|
serverId: leaveSid,
|
||||||
serverIds: Array.from(user.serverIds)
|
serverIds: remainingServerIds
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||||
const targetUserId = String(message['targetUserId'] || '');
|
const targetUserId = readMessageId(message['targetUserId']) ?? '';
|
||||||
|
|
||||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
||||||
|
|
||||||
@@ -128,11 +182,15 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
|||||||
|
|
||||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||||
|
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
|
||||||
|
? message['channelId'].trim()
|
||||||
|
: 'general';
|
||||||
|
|
||||||
if (typingSid && user.serverIds.has(typingSid)) {
|
if (typingSid && user.serverIds.has(typingSid)) {
|
||||||
broadcastToServer(typingSid, {
|
broadcastToServer(typingSid, {
|
||||||
type: 'user_typing',
|
type: 'user_typing',
|
||||||
serverId: typingSid,
|
serverId: typingSid,
|
||||||
|
channelId,
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName
|
displayName: user.displayName
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { broadcastToServer } from './broadcast';
|
import {
|
||||||
|
broadcastToServer,
|
||||||
|
getServerIdsForOderId,
|
||||||
|
isOderIdConnectedToServer
|
||||||
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
/** How often to ping all connected clients (ms). */
|
/** How often to ping all connected clients (ms). */
|
||||||
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
if (user) {
|
if (user) {
|
||||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
user.serverIds.forEach((sid) => {
|
user.serverIds.forEach((sid) => {
|
||||||
|
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
serverId: sid,
|
serverId: sid,
|
||||||
serverIds: []
|
serverIds: remainingServerIds
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<div class="h-screen bg-background text-foreground flex">
|
|
||||||
<!-- Global left servers rail always visible -->
|
|
||||||
<aside class="w-16 flex-shrink-0 border-r border-border bg-card">
|
|
||||||
<app-servers-rail class="h-full" />
|
|
||||||
</aside>
|
|
||||||
<main class="flex-1 min-w-0 relative overflow-hidden">
|
|
||||||
<!-- Custom draggable title bar -->
|
|
||||||
<app-title-bar />
|
|
||||||
|
|
||||||
@if (desktopUpdateState().restartRequired) {
|
|
||||||
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
|
|
||||||
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
|
||||||
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="openUpdatesSettings()"
|
|
||||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
>
|
|
||||||
Update settings
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="restartToApplyUpdate()"
|
|
||||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Restart now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Content area fills below the title bar without global scroll -->
|
|
||||||
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
|
|
||||||
<router-outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
|
||||||
<app-floating-voice-controls />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
|
||||||
<app-settings-modal />
|
|
||||||
|
|
||||||
<!-- Shared Screen Share Source Picker -->
|
|
||||||
<app-screen-share-source-picker />
|
|
||||||
|
|
||||||
<!-- Shared Debug Console -->
|
|
||||||
<app-debug-console [showLauncher]="false" />
|
|
||||||
230
src/app/app.ts
230
src/app/app.ts
@@ -1,230 +0,0 @@
|
|||||||
/* eslint-disable @angular-eslint/component-class-suffix */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
OnInit,
|
|
||||||
OnDestroy,
|
|
||||||
inject,
|
|
||||||
HostListener
|
|
||||||
} from '@angular/core';
|
|
||||||
import {
|
|
||||||
Router,
|
|
||||||
RouterOutlet,
|
|
||||||
NavigationEnd
|
|
||||||
} from '@angular/router';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
|
|
||||||
import { DatabaseService } from './core/services/database.service';
|
|
||||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
|
||||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
|
||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
|
||||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
|
||||||
import { ExternalLinkService } from './core/services/external-link.service';
|
|
||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
|
||||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
|
||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
|
||||||
import { UsersActions } from './store/users/users.actions';
|
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
|
||||||
import {
|
|
||||||
ROOM_URL_PATTERN,
|
|
||||||
STORAGE_KEY_CURRENT_USER_ID,
|
|
||||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
|
||||||
} from './core/constants';
|
|
||||||
|
|
||||||
interface DeepLinkElectronApi {
|
|
||||||
consumePendingDeepLink?: () => Promise<string | null>;
|
|
||||||
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeepLinkWindow = Window & {
|
|
||||||
electronAPI?: DeepLinkElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
RouterOutlet,
|
|
||||||
ServersRailComponent,
|
|
||||||
TitleBarComponent,
|
|
||||||
FloatingVoiceControlsComponent,
|
|
||||||
SettingsModalComponent,
|
|
||||||
DebugConsoleComponent,
|
|
||||||
ScreenShareSourcePickerComponent
|
|
||||||
],
|
|
||||||
templateUrl: './app.html',
|
|
||||||
styleUrl: './app.scss'
|
|
||||||
})
|
|
||||||
export class App implements OnInit, OnDestroy {
|
|
||||||
store = inject(Store);
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
|
||||||
desktopUpdateState = this.desktopUpdates.state;
|
|
||||||
|
|
||||||
private databaseService = inject(DatabaseService);
|
|
||||||
private router = inject(Router);
|
|
||||||
private servers = inject(ServerDirectoryService);
|
|
||||||
private settingsModal = inject(SettingsModalService);
|
|
||||||
private timeSync = inject(TimeSyncService);
|
|
||||||
private voiceSession = inject(VoiceSessionService);
|
|
||||||
private externalLinks = inject(ExternalLinkService);
|
|
||||||
private deepLinkCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
|
||||||
this.externalLinks.handleClick(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
|
||||||
void this.desktopUpdates.initialize();
|
|
||||||
|
|
||||||
await this.databaseService.initialize();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiBase = this.servers.getApiBaseUrl();
|
|
||||||
|
|
||||||
await this.timeSync.syncWithEndpoint(apiBase);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
await this.setupDesktopDeepLinks();
|
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
|
||||||
|
|
||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
|
||||||
|
|
||||||
if (!currentUserId) {
|
|
||||||
if (!this.isPublicRoute(this.router.url)) {
|
|
||||||
this.router.navigate(['/login'], {
|
|
||||||
queryParams: {
|
|
||||||
returnUrl: this.router.url
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
|
||||||
|
|
||||||
if (last && typeof last === 'string') {
|
|
||||||
const current = this.router.url;
|
|
||||||
|
|
||||||
if (current === '/' || current === '/search') {
|
|
||||||
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.events.subscribe((evt) => {
|
|
||||||
if (evt instanceof NavigationEnd) {
|
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
|
||||||
|
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
|
||||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
|
||||||
|
|
||||||
this.voiceSession.checkCurrentRoute(currentRoomId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.deepLinkCleanup?.();
|
|
||||||
this.deepLinkCleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
openNetworkSettings(): void {
|
|
||||||
this.settingsModal.open('network');
|
|
||||||
}
|
|
||||||
|
|
||||||
openUpdatesSettings(): void {
|
|
||||||
this.settingsModal.open('updates');
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshDesktopUpdateContext(): Promise<void> {
|
|
||||||
await this.desktopUpdates.refreshServerContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
async restartToApplyUpdate(): Promise<void> {
|
|
||||||
await this.desktopUpdates.restartToApplyUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setupDesktopDeepLinks(): Promise<void> {
|
|
||||||
const electronApi = this.getDeepLinkElectronApi();
|
|
||||||
|
|
||||||
if (!electronApi) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
|
|
||||||
void this.handleDesktopDeepLink(url);
|
|
||||||
}) || null;
|
|
||||||
|
|
||||||
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
|
|
||||||
|
|
||||||
if (pendingDeepLink) {
|
|
||||||
await this.handleDesktopDeepLink(pendingDeepLink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleDesktopDeepLink(url: string): Promise<void> {
|
|
||||||
const invite = this.parseDesktopInviteUrl(url);
|
|
||||||
|
|
||||||
if (!invite) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.router.navigate(['/invite', invite.inviteId], {
|
|
||||||
queryParams: {
|
|
||||||
server: invite.sourceUrl
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDeepLinkElectronApi(): DeepLinkElectronApi | null {
|
|
||||||
return typeof window !== 'undefined'
|
|
||||||
? (window as DeepLinkWindow).electronAPI ?? null
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isPublicRoute(url: string): boolean {
|
|
||||||
return url === '/login' ||
|
|
||||||
url === '/register' ||
|
|
||||||
url.startsWith('/invite/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
|
||||||
try {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
|
|
||||||
if (parsedUrl.protocol !== 'toju:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
|
|
||||||
.map((segment) => decodeURIComponent(segment));
|
|
||||||
|
|
||||||
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
|
|
||||||
|
|
||||||
if (!sourceUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inviteId: pathSegments[1],
|
|
||||||
sourceUrl
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
|
||||||
|
|
||||||
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
|
||||||
|
|
||||||
export type ChannelType = 'text' | 'voice';
|
|
||||||
|
|
||||||
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
oderId: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
status: UserStatus;
|
|
||||||
role: UserRole;
|
|
||||||
joinedAt: number;
|
|
||||||
peerId?: string;
|
|
||||||
isOnline?: boolean;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
isRoomOwner?: boolean;
|
|
||||||
voiceState?: VoiceState;
|
|
||||||
screenShareState?: ScreenShareState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomMember {
|
|
||||||
id: string;
|
|
||||||
oderId?: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
role: UserRole;
|
|
||||||
joinedAt: number;
|
|
||||||
lastSeenAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Channel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: ChannelType;
|
|
||||||
position: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
id: string;
|
|
||||||
roomId: string;
|
|
||||||
channelId?: string;
|
|
||||||
senderId: string;
|
|
||||||
senderName: string;
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
editedAt?: number;
|
|
||||||
reactions: Reaction[];
|
|
||||||
isDeleted: boolean;
|
|
||||||
replyToId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Reaction {
|
|
||||||
id: string;
|
|
||||||
messageId: string;
|
|
||||||
oderId: string;
|
|
||||||
userId: string;
|
|
||||||
emoji: string;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Room {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
topic?: string;
|
|
||||||
hostId: string;
|
|
||||||
password?: string;
|
|
||||||
hasPassword?: boolean;
|
|
||||||
isPrivate: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
userCount: number;
|
|
||||||
maxUsers?: number;
|
|
||||||
icon?: string;
|
|
||||||
iconUpdatedAt?: number;
|
|
||||||
permissions?: RoomPermissions;
|
|
||||||
channels?: Channel[];
|
|
||||||
members?: RoomMember[];
|
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomSettings {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
topic?: string;
|
|
||||||
isPrivate: boolean;
|
|
||||||
password?: string;
|
|
||||||
hasPassword?: boolean;
|
|
||||||
maxUsers?: number;
|
|
||||||
rules?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomPermissions {
|
|
||||||
adminsManageRooms?: boolean;
|
|
||||||
moderatorsManageRooms?: boolean;
|
|
||||||
adminsManageIcon?: boolean;
|
|
||||||
moderatorsManageIcon?: boolean;
|
|
||||||
allowVoice?: boolean;
|
|
||||||
allowScreenShare?: boolean;
|
|
||||||
allowFileUploads?: boolean;
|
|
||||||
slowModeInterval?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BanEntry {
|
|
||||||
oderId: string;
|
|
||||||
userId: string;
|
|
||||||
roomId: string;
|
|
||||||
bannedBy: string;
|
|
||||||
displayName?: string;
|
|
||||||
reason?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PeerConnection {
|
|
||||||
peerId: string;
|
|
||||||
userId: string;
|
|
||||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
|
||||||
dataChannel?: RTCDataChannel;
|
|
||||||
connection?: RTCPeerConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoiceState {
|
|
||||||
isConnected: boolean;
|
|
||||||
isMuted: boolean;
|
|
||||||
isDeafened: boolean;
|
|
||||||
isSpeaking: boolean;
|
|
||||||
isMutedByAdmin?: boolean;
|
|
||||||
volume?: number;
|
|
||||||
roomId?: string;
|
|
||||||
serverId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenShareState {
|
|
||||||
isSharing: boolean;
|
|
||||||
streamId?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SignalingMessageType =
|
|
||||||
| 'offer'
|
|
||||||
| 'answer'
|
|
||||||
| 'ice-candidate'
|
|
||||||
| 'join'
|
|
||||||
| 'leave'
|
|
||||||
| 'chat'
|
|
||||||
| 'state-sync'
|
|
||||||
| 'kick'
|
|
||||||
| 'ban'
|
|
||||||
| 'host-change'
|
|
||||||
| 'room-update';
|
|
||||||
|
|
||||||
export interface SignalingMessage {
|
|
||||||
type: SignalingMessageType;
|
|
||||||
from: string;
|
|
||||||
to?: string;
|
|
||||||
payload: unknown;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChatEventType =
|
|
||||||
| 'message'
|
|
||||||
| 'chat-message'
|
|
||||||
| 'edit'
|
|
||||||
| 'message-edited'
|
|
||||||
| 'delete'
|
|
||||||
| 'message-deleted'
|
|
||||||
| 'reaction'
|
|
||||||
| 'reaction-added'
|
|
||||||
| 'reaction-removed'
|
|
||||||
| 'kick'
|
|
||||||
| 'ban'
|
|
||||||
| 'room-deleted'
|
|
||||||
| 'host-change'
|
|
||||||
| 'room-settings-update'
|
|
||||||
| 'voice-state'
|
|
||||||
| 'chat-inventory-request'
|
|
||||||
| 'chat-inventory'
|
|
||||||
| 'chat-sync-request-ids'
|
|
||||||
| 'chat-sync-batch'
|
|
||||||
| 'chat-sync-summary'
|
|
||||||
| 'chat-sync-request'
|
|
||||||
| 'chat-sync-full'
|
|
||||||
| 'file-announce'
|
|
||||||
| 'file-chunk'
|
|
||||||
| 'file-request'
|
|
||||||
| 'file-cancel'
|
|
||||||
| 'file-not-found'
|
|
||||||
| 'member-roster-request'
|
|
||||||
| 'member-roster'
|
|
||||||
| 'member-leave'
|
|
||||||
| 'voice-state-request'
|
|
||||||
| 'state-request'
|
|
||||||
| 'screen-state'
|
|
||||||
| 'screen-share-request'
|
|
||||||
| 'screen-share-stop'
|
|
||||||
| 'role-change'
|
|
||||||
| 'room-permissions-update'
|
|
||||||
| 'server-icon-summary'
|
|
||||||
| 'server-icon-request'
|
|
||||||
| 'server-icon-full'
|
|
||||||
| 'server-icon-update'
|
|
||||||
| 'server-state-request'
|
|
||||||
| 'server-state-full'
|
|
||||||
| 'unban'
|
|
||||||
| 'channels-update';
|
|
||||||
|
|
||||||
export interface ChatInventoryItem {
|
|
||||||
id: string;
|
|
||||||
ts: number;
|
|
||||||
rc: number;
|
|
||||||
ac?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatAttachmentAnnouncement {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
mime: string;
|
|
||||||
isImage: boolean;
|
|
||||||
uploaderPeerId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
|
|
||||||
messageId: string;
|
|
||||||
filePath?: string;
|
|
||||||
savedPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Optional fields depend on `type`. */
|
|
||||||
export interface ChatEvent {
|
|
||||||
type: ChatEventType;
|
|
||||||
fromPeerId?: string;
|
|
||||||
messageId?: string;
|
|
||||||
message?: Message;
|
|
||||||
reaction?: Reaction;
|
|
||||||
data?: string | Partial<Message>;
|
|
||||||
timestamp?: number;
|
|
||||||
targetUserId?: string;
|
|
||||||
roomId?: string;
|
|
||||||
items?: ChatInventoryItem[];
|
|
||||||
ids?: string[];
|
|
||||||
messages?: Message[];
|
|
||||||
attachments?: Record<string, ChatAttachmentMeta[]>;
|
|
||||||
total?: number;
|
|
||||||
index?: number;
|
|
||||||
count?: number;
|
|
||||||
lastUpdated?: number;
|
|
||||||
file?: ChatAttachmentAnnouncement;
|
|
||||||
fileId?: string;
|
|
||||||
hostId?: string;
|
|
||||||
hostOderId?: string;
|
|
||||||
previousHostId?: string;
|
|
||||||
previousHostOderId?: string;
|
|
||||||
kickedBy?: string;
|
|
||||||
bannedBy?: string;
|
|
||||||
content?: string;
|
|
||||||
editedAt?: number;
|
|
||||||
deletedAt?: number;
|
|
||||||
deletedBy?: string;
|
|
||||||
oderId?: string;
|
|
||||||
displayName?: string;
|
|
||||||
emoji?: string;
|
|
||||||
reason?: string;
|
|
||||||
settings?: Partial<RoomSettings>;
|
|
||||||
permissions?: Partial<RoomPermissions>;
|
|
||||||
voiceState?: Partial<VoiceState>;
|
|
||||||
isScreenSharing?: boolean;
|
|
||||||
icon?: string;
|
|
||||||
iconUpdatedAt?: number;
|
|
||||||
role?: UserRole;
|
|
||||||
room?: Partial<Room>;
|
|
||||||
channels?: Channel[];
|
|
||||||
members?: RoomMember[];
|
|
||||||
ban?: BanEntry;
|
|
||||||
bans?: BanEntry[];
|
|
||||||
banOderId?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
topic?: string;
|
|
||||||
hostName: string;
|
|
||||||
ownerId?: string;
|
|
||||||
ownerName?: string;
|
|
||||||
ownerPublicKey?: string;
|
|
||||||
userCount: number;
|
|
||||||
maxUsers: number;
|
|
||||||
hasPassword?: boolean;
|
|
||||||
isPrivate: boolean;
|
|
||||||
tags?: string[];
|
|
||||||
createdAt: number;
|
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JoinRequest {
|
|
||||||
roomId: string;
|
|
||||||
userId: string;
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppState {
|
|
||||||
currentUser: User | null;
|
|
||||||
currentRoom: Room | null;
|
|
||||||
isConnecting: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
|||||||
export * from './notification-audio.service';
|
|
||||||
export * from './platform.service';
|
|
||||||
export * from './browser-database.service';
|
|
||||||
export * from './electron-database.service';
|
|
||||||
export * from './database.service';
|
|
||||||
export * from '../models/debugging.models';
|
|
||||||
export * from './debugging/debugging.service';
|
|
||||||
export * from './webrtc.service';
|
|
||||||
export * from './server-directory.service';
|
|
||||||
export * from './klipy.service';
|
|
||||||
export * from './voice-session.service';
|
|
||||||
export * from './voice-activity.service';
|
|
||||||
export * from './external-link.service';
|
|
||||||
export * from './settings-modal.service';
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
type ElectronPlatformWindow = Window & {
|
|
||||||
electronAPI?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PlatformService {
|
|
||||||
readonly isElectron: boolean;
|
|
||||||
readonly isBrowser: boolean;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.isElectron =
|
|
||||||
typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI;
|
|
||||||
|
|
||||||
this.isBrowser = !this.isElectron;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
|
||||||
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class SettingsModalService {
|
|
||||||
readonly isOpen = signal(false);
|
|
||||||
readonly activePage = signal<SettingsPage>('general');
|
|
||||||
readonly targetServerId = signal<string | null>(null);
|
|
||||||
|
|
||||||
open(page: SettingsPage = 'general', serverId?: string): void {
|
|
||||||
this.activePage.set(page);
|
|
||||||
this.targetServerId.set(serverId ?? null);
|
|
||||||
this.isOpen.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.isOpen.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(page: SettingsPage): void {
|
|
||||||
this.activePage.set(page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Barrel export for the WebRTC sub-module.
|
|
||||||
*
|
|
||||||
* Other modules should import from here:
|
|
||||||
* import { ... } from './webrtc';
|
|
||||||
*/
|
|
||||||
export * from './webrtc.constants';
|
|
||||||
export * from './webrtc.types';
|
|
||||||
export * from './webrtc-logger';
|
|
||||||
export * from './signaling.manager';
|
|
||||||
export * from './peer-connection.manager';
|
|
||||||
export * from './media.manager';
|
|
||||||
export * from './screen-share.manager';
|
|
||||||
export * from './screen-share.config';
|
|
||||||
export * from './noise-reduction.manager';
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
export interface DesktopSource {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
thumbnail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronDesktopSourceSelection {
|
|
||||||
includeSystemAudio: boolean;
|
|
||||||
source: DesktopSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronDesktopCaptureResult {
|
|
||||||
includeSystemAudio: boolean;
|
|
||||||
stream: MediaStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
|
||||||
available: boolean;
|
|
||||||
active: boolean;
|
|
||||||
monitorCaptureSupported: boolean;
|
|
||||||
screenShareSinkName: string;
|
|
||||||
screenShareMonitorSourceName: string;
|
|
||||||
voiceSinkName: string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareMonitorCaptureInfo {
|
|
||||||
bitsPerSample: number;
|
|
||||||
captureId: string;
|
|
||||||
channelCount: number;
|
|
||||||
sampleRate: number;
|
|
||||||
sourceName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareMonitorAudioChunkPayload {
|
|
||||||
captureId: string;
|
|
||||||
chunk: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareMonitorAudioEndedPayload {
|
|
||||||
captureId: string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenShareElectronApi {
|
|
||||||
getSources?: () => Promise<DesktopSource[]>;
|
|
||||||
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
|
||||||
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
|
||||||
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
|
|
||||||
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
|
||||||
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
|
|
||||||
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
|
||||||
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'desktop';
|
|
||||||
chromeMediaSourceId: string;
|
|
||||||
maxWidth: number;
|
|
||||||
maxHeight: number;
|
|
||||||
maxFrameRate: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'desktop';
|
|
||||||
chromeMediaSourceId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
|
|
||||||
video: ElectronDesktopVideoConstraint;
|
|
||||||
audio?: false | ElectronDesktopAudioConstraint;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ScreenShareWindow = Window & {
|
|
||||||
electronAPI?: ScreenShareElectronApi;
|
|
||||||
};
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
@if (isAdmin()) {
|
|
||||||
<div class="h-full flex flex-col bg-card">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="p-4 border-b border-border flex items-center gap-2">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideShield"
|
|
||||||
class="w-5 h-5 text-primary"
|
|
||||||
/>
|
|
||||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="flex border-b border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="activeTab.set('settings')"
|
|
||||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
[class.text-primary]="activeTab() === 'settings'"
|
|
||||||
[class.border-b-2]="activeTab() === 'settings'"
|
|
||||||
[class.border-primary]="activeTab() === 'settings'"
|
|
||||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideSettings"
|
|
||||||
class="w-4 h-4 inline mr-1"
|
|
||||||
/>
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="activeTab.set('members')"
|
|
||||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
[class.text-primary]="activeTab() === 'members'"
|
|
||||||
[class.border-b-2]="activeTab() === 'members'"
|
|
||||||
[class.border-primary]="activeTab() === 'members'"
|
|
||||||
[class.text-muted-foreground]="activeTab() !== 'members'"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideUsers"
|
|
||||||
class="w-4 h-4 inline mr-1"
|
|
||||||
/>
|
|
||||||
Members
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="activeTab.set('bans')"
|
|
||||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
[class.text-primary]="activeTab() === 'bans'"
|
|
||||||
[class.border-b-2]="activeTab() === 'bans'"
|
|
||||||
[class.border-primary]="activeTab() === 'bans'"
|
|
||||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideBan"
|
|
||||||
class="w-4 h-4 inline mr-1"
|
|
||||||
/>
|
|
||||||
Bans
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="activeTab.set('permissions')"
|
|
||||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
[class.text-primary]="activeTab() === 'permissions'"
|
|
||||||
[class.border-b-2]="activeTab() === 'permissions'"
|
|
||||||
[class.border-primary]="activeTab() === 'permissions'"
|
|
||||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideShield"
|
|
||||||
class="w-4 h-4 inline mr-1"
|
|
||||||
/>
|
|
||||||
Perms
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4">
|
|
||||||
@switch (activeTab()) {
|
|
||||||
@case ('settings') {
|
|
||||||
<div class="space-y-6">
|
|
||||||
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
|
|
||||||
|
|
||||||
<!-- Room Name -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="room-name-input"
|
|
||||||
class="block text-sm text-muted-foreground mb-1"
|
|
||||||
>Room Name</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="room-name-input"
|
|
||||||
[(ngModel)]="roomName"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Room Description -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="room-description-input"
|
|
||||||
class="block text-sm text-muted-foreground mb-1"
|
|
||||||
>Description</label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
id="room-description-input"
|
|
||||||
[(ngModel)]="roomDescription"
|
|
||||||
rows="3"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Private Room Toggle -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Private Room</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Require approval to join</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="togglePrivate()"
|
|
||||||
class="p-2 rounded-lg transition-colors"
|
|
||||||
[class.bg-primary]="isPrivate()"
|
|
||||||
[class.text-primary-foreground]="isPrivate()"
|
|
||||||
[class.bg-secondary]="!isPrivate()"
|
|
||||||
[class.text-muted-foreground]="!isPrivate()"
|
|
||||||
>
|
|
||||||
@if (isPrivate()) {
|
|
||||||
<ng-icon
|
|
||||||
name="lucideLock"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<ng-icon
|
|
||||||
name="lucideUnlock"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Max Users -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="max-users-input"
|
|
||||||
class="block text-sm text-muted-foreground mb-1"
|
|
||||||
>Max Users (0 = unlimited)</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="max-users-input"
|
|
||||||
[(ngModel)]="maxUsers"
|
|
||||||
min="0"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save Button -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="saveSettings()"
|
|
||||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideCheck"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
Save Settings
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
|
||||||
<div class="pt-4 border-t border-border">
|
|
||||||
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="confirmDeleteRoom()"
|
|
||||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideTrash2"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
Delete Room
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('members') {
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
|
||||||
|
|
||||||
@if (membersFiltered().length === 0) {
|
|
||||||
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
|
|
||||||
} @else {
|
|
||||||
@for (user of membersFiltered(); track user.id) {
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<app-user-avatar
|
|
||||||
[name]="user.displayName || '?'"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
|
||||||
@if (user.role === 'host') {
|
|
||||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
|
||||||
} @else if (user.role === 'admin') {
|
|
||||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
|
||||||
} @else if (user.role === 'moderator') {
|
|
||||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Role actions (only for non-hosts) -->
|
|
||||||
@if (user.role !== 'host') {
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<select
|
|
||||||
[ngModel]="user.role"
|
|
||||||
(ngModelChange)="changeRole(user, $event)"
|
|
||||||
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="member">Member</option>
|
|
||||||
<option value="moderator">Moderator</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="kickMember(user)"
|
|
||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Kick"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideUserX"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="banMember(user)"
|
|
||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Ban"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideBan"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('bans') {
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
|
||||||
|
|
||||||
@if (bannedUsers().length === 0) {
|
|
||||||
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
|
|
||||||
} @else {
|
|
||||||
@for (ban of bannedUsers(); track ban.oderId) {
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
|
|
||||||
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium text-foreground truncate">
|
|
||||||
{{ ban.displayName || 'Unknown User' }}
|
|
||||||
</p>
|
|
||||||
@if (ban.reason) {
|
|
||||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
|
||||||
}
|
|
||||||
@if (ban.expiresAt) {
|
|
||||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
|
||||||
} @else {
|
|
||||||
<p class="text-xs text-destructive">Permanent</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="unbanUser(ban)"
|
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideX"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('permissions') {
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
|
|
||||||
|
|
||||||
<!-- Permission Toggles -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="allowVoice"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Users can share their screen</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="allowScreenShare"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Users can upload files</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="allowFileUploads"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Slow Mode</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Limit message frequency</p>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
[(ngModel)]="slowModeInterval"
|
|
||||||
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="0">Off</option>
|
|
||||||
<option value="5">5 seconds</option>
|
|
||||||
<option value="10">10 seconds</option>
|
|
||||||
<option value="30">30 seconds</option>
|
|
||||||
<option value="60">1 minute</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Management Permissions -->
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="adminsManageRooms"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="moderatorsManageRooms"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="adminsManageIcon"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="moderatorsManageIcon"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save Permissions -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="savePermissions()"
|
|
||||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideCheck"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
Save Permissions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
@if (showDeleteConfirm()) {
|
|
||||||
<app-confirm-dialog
|
|
||||||
title="Delete Room"
|
|
||||||
confirmLabel="Delete Room"
|
|
||||||
variant="danger"
|
|
||||||
[widthClass]="'w-96 max-w-[90vw]'"
|
|
||||||
(confirmed)="deleteRoom()"
|
|
||||||
(cancelled)="showDeleteConfirm.set(false)"
|
|
||||||
>
|
|
||||||
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
|
|
||||||
</app-confirm-dialog>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
|
||||||
<p>You don't have admin permissions</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import {
|
|
||||||
lucideShield,
|
|
||||||
lucideBan,
|
|
||||||
lucideUserX,
|
|
||||||
lucideSettings,
|
|
||||||
lucideUsers,
|
|
||||||
lucideTrash2,
|
|
||||||
lucideCheck,
|
|
||||||
lucideX,
|
|
||||||
lucideLock,
|
|
||||||
lucideUnlock
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
|
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
|
||||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
|
||||||
import {
|
|
||||||
selectBannedUsers,
|
|
||||||
selectIsCurrentUserAdmin,
|
|
||||||
selectCurrentUser,
|
|
||||||
selectOnlineUsers
|
|
||||||
} from '../../../store/users/users.selectors';
|
|
||||||
import { BanEntry, User } from '../../../core/models/index';
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
|
||||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
|
||||||
|
|
||||||
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-admin-panel',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
NgIcon,
|
|
||||||
UserAvatarComponent,
|
|
||||||
ConfirmDialogComponent
|
|
||||||
],
|
|
||||||
viewProviders: [
|
|
||||||
provideIcons({
|
|
||||||
lucideShield,
|
|
||||||
lucideBan,
|
|
||||||
lucideUserX,
|
|
||||||
lucideSettings,
|
|
||||||
lucideUsers,
|
|
||||||
lucideTrash2,
|
|
||||||
lucideCheck,
|
|
||||||
lucideX,
|
|
||||||
lucideLock,
|
|
||||||
lucideUnlock
|
|
||||||
})
|
|
||||||
],
|
|
||||||
templateUrl: './admin-panel.component.html'
|
|
||||||
})
|
|
||||||
/**
|
|
||||||
* Admin panel for managing room settings, members, bans, and permissions.
|
|
||||||
* Only accessible to users with admin privileges.
|
|
||||||
*/
|
|
||||||
export class AdminPanelComponent {
|
|
||||||
store = inject(Store);
|
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
|
||||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
|
||||||
|
|
||||||
activeTab = signal<AdminTab>('settings');
|
|
||||||
showDeleteConfirm = signal(false);
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
roomName = '';
|
|
||||||
roomDescription = '';
|
|
||||||
isPrivate = signal(false);
|
|
||||||
maxUsers = 0;
|
|
||||||
|
|
||||||
// Permissions
|
|
||||||
allowVoice = true;
|
|
||||||
allowScreenShare = true;
|
|
||||||
allowFileUploads = true;
|
|
||||||
slowModeInterval = '0';
|
|
||||||
adminsManageRooms = false;
|
|
||||||
moderatorsManageRooms = false;
|
|
||||||
adminsManageIcon = false;
|
|
||||||
moderatorsManageIcon = false;
|
|
||||||
|
|
||||||
private webrtc = inject(WebRTCService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Initialize from current room
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
if (room) {
|
|
||||||
this.roomName = room.name;
|
|
||||||
this.roomDescription = room.description || '';
|
|
||||||
this.isPrivate.set(room.isPrivate);
|
|
||||||
this.maxUsers = room.maxUsers || 0;
|
|
||||||
const perms = room.permissions || {};
|
|
||||||
|
|
||||||
this.allowVoice = perms.allowVoice !== false;
|
|
||||||
this.allowScreenShare = perms.allowScreenShare !== false;
|
|
||||||
this.allowFileUploads = perms.allowFileUploads !== false;
|
|
||||||
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
|
|
||||||
this.adminsManageRooms = !!perms.adminsManageRooms;
|
|
||||||
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
|
|
||||||
this.adminsManageIcon = !!perms.adminsManageIcon;
|
|
||||||
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle the room's private visibility setting. */
|
|
||||||
togglePrivate(): void {
|
|
||||||
this.isPrivate.update((current) => !current);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save the current room name, description, privacy, and max-user settings. */
|
|
||||||
saveSettings(): void {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
if (!room)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.updateRoomSettings({
|
|
||||||
roomId: room.id,
|
|
||||||
settings: {
|
|
||||||
name: this.roomName,
|
|
||||||
description: this.roomDescription,
|
|
||||||
isPrivate: this.isPrivate(),
|
|
||||||
maxUsers: this.maxUsers
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
|
|
||||||
savePermissions(): void {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
if (!room)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.updateRoomPermissions({
|
|
||||||
roomId: room.id,
|
|
||||||
permissions: {
|
|
||||||
allowVoice: this.allowVoice,
|
|
||||||
allowScreenShare: this.allowScreenShare,
|
|
||||||
allowFileUploads: this.allowFileUploads,
|
|
||||||
slowModeInterval: parseInt(this.slowModeInterval, 10),
|
|
||||||
adminsManageRooms: this.adminsManageRooms,
|
|
||||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
|
||||||
adminsManageIcon: this.adminsManageIcon,
|
|
||||||
moderatorsManageIcon: this.moderatorsManageIcon
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove a user's ban entry. */
|
|
||||||
unbanUser(ban: BanEntry): void {
|
|
||||||
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
|
|
||||||
oderId: ban.oderId }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show the delete-room confirmation dialog. */
|
|
||||||
confirmDeleteRoom(): void {
|
|
||||||
this.showDeleteConfirm.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete the current room after confirmation. */
|
|
||||||
deleteRoom(): void {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
if (!room)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
|
||||||
this.showDeleteConfirm.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a ban expiry timestamp into a human-readable date/time string. */
|
|
||||||
formatExpiry(timestamp: number): string {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
|
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
|
|
||||||
minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Members tab: get all users except self
|
|
||||||
/** Return online users excluding the current user (for the members list). */
|
|
||||||
membersFiltered(): User[] {
|
|
||||||
const me = this.currentUser();
|
|
||||||
|
|
||||||
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Change a member's role and notify connected peers. */
|
|
||||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
|
||||||
const roomId = this.currentRoom()?.id;
|
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
|
||||||
role }));
|
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'role-change',
|
|
||||||
roomId,
|
|
||||||
targetUserId: user.id,
|
|
||||||
role
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kick a member from the server. */
|
|
||||||
kickMember(user: User): void {
|
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ban a member from the server. */
|
|
||||||
banMember(user: User): void {
|
|
||||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
.chat-textarea {
|
|
||||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
|
|
||||||
background: var(--textarea-bg);
|
|
||||||
height: 62px;
|
|
||||||
min-height: 62px;
|
|
||||||
max-height: 520px;
|
|
||||||
overflow-y: hidden;
|
|
||||||
resize: none;
|
|
||||||
transition: height 0.12s ease;
|
|
||||||
|
|
||||||
&.ctrl-resize {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transform: scale(0.85);
|
|
||||||
transition:
|
|
||||||
opacity 0.2s ease,
|
|
||||||
transform 0.2s ease;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<div class="h-full flex flex-col bg-background">
|
|
||||||
@if (currentRoom()) {
|
|
||||||
<!-- Channel header bar -->
|
|
||||||
@if (!isVoiceWorkspaceExpanded()) {
|
|
||||||
<div class="h-12 flex items-center gap-2 px-4 border-b border-border bg-card flex-shrink-0">
|
|
||||||
<ng-icon
|
|
||||||
[name]="isVoiceWorkspaceExpanded() ? 'lucideMonitor' : 'lucideHash'"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="font-medium text-foreground text-sm">{{ headerTitle() }}</span>
|
|
||||||
|
|
||||||
@if (isVoiceWorkspaceExpanded()) {
|
|
||||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-primary">
|
|
||||||
Voice streams
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="flex-1 flex overflow-hidden">
|
|
||||||
<!-- Chat Area -->
|
|
||||||
<main class="relative flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
class="h-full overflow-hidden"
|
|
||||||
[class.hidden]="isVoiceWorkspaceExpanded()"
|
|
||||||
>
|
|
||||||
<app-chat-messages />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-screen-share-workspace />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Sidebar always visible -->
|
|
||||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
|
||||||
<app-rooms-side-panel class="h-full" />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- No Room Selected -->
|
|
||||||
<div class="flex-1 flex items-center justify-center">
|
|
||||||
<div class="text-center text-muted-foreground">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideHash"
|
|
||||||
class="w-16 h-16 mx-auto mb-4 opacity-30"
|
|
||||||
/>
|
|
||||||
<h2 class="text-xl font-medium mb-2">No room selected</h2>
|
|
||||||
<p class="text-sm">Select or create a room to start chatting</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,628 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
computed,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import {
|
|
||||||
lucideMessageSquare,
|
|
||||||
lucideMic,
|
|
||||||
lucideMicOff,
|
|
||||||
lucideChevronLeft,
|
|
||||||
lucideMonitor,
|
|
||||||
lucideHash,
|
|
||||||
lucideUsers,
|
|
||||||
lucidePlus,
|
|
||||||
lucideVolumeX
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import {
|
|
||||||
selectOnlineUsers,
|
|
||||||
selectCurrentUser,
|
|
||||||
selectIsCurrentUserAdmin
|
|
||||||
} from '../../../store/users/users.selectors';
|
|
||||||
import {
|
|
||||||
selectCurrentRoom,
|
|
||||||
selectActiveChannelId,
|
|
||||||
selectTextChannels,
|
|
||||||
selectVoiceChannels
|
|
||||||
} from '../../../store/rooms/rooms.selectors';
|
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
|
||||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
|
||||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
|
||||||
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
|
|
||||||
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
|
||||||
import { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service';
|
|
||||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
|
||||||
import {
|
|
||||||
ContextMenuComponent,
|
|
||||||
UserAvatarComponent,
|
|
||||||
ConfirmDialogComponent,
|
|
||||||
UserVolumeMenuComponent
|
|
||||||
} from '../../../shared';
|
|
||||||
import {
|
|
||||||
Channel,
|
|
||||||
ChatEvent,
|
|
||||||
RoomMember,
|
|
||||||
Room,
|
|
||||||
User
|
|
||||||
} from '../../../core/models/index';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
type TabView = 'channels' | 'users';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-rooms-side-panel',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
NgIcon,
|
|
||||||
VoiceControlsComponent,
|
|
||||||
ContextMenuComponent,
|
|
||||||
UserVolumeMenuComponent,
|
|
||||||
UserAvatarComponent,
|
|
||||||
ConfirmDialogComponent
|
|
||||||
],
|
|
||||||
viewProviders: [
|
|
||||||
provideIcons({
|
|
||||||
lucideMessageSquare,
|
|
||||||
lucideMic,
|
|
||||||
lucideMicOff,
|
|
||||||
lucideChevronLeft,
|
|
||||||
lucideMonitor,
|
|
||||||
lucideHash,
|
|
||||||
lucideUsers,
|
|
||||||
lucidePlus,
|
|
||||||
lucideVolumeX
|
|
||||||
})
|
|
||||||
],
|
|
||||||
templateUrl: './rooms-side-panel.component.html'
|
|
||||||
})
|
|
||||||
export class RoomsSidePanelComponent {
|
|
||||||
private store = inject(Store);
|
|
||||||
private webrtc = inject(WebRTCService);
|
|
||||||
private voiceSessionService = inject(VoiceSessionService);
|
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
|
||||||
private voicePlayback = inject(VoicePlaybackService);
|
|
||||||
voiceActivity = inject(VoiceActivityService);
|
|
||||||
|
|
||||||
activeTab = signal<TabView>('channels');
|
|
||||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
|
||||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
|
||||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
|
||||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
|
||||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
|
||||||
offlineRoomMembers = computed(() => {
|
|
||||||
const current = this.currentUser();
|
|
||||||
const onlineIds = new Set(this.onlineUsers().map((user) => user.oderId || user.id));
|
|
||||||
|
|
||||||
if (current) {
|
|
||||||
onlineIds.add(current.oderId || current.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.roomMembers().filter((member) => !onlineIds.has(this.roomMemberKey(member)));
|
|
||||||
});
|
|
||||||
knownUserCount = computed(() => {
|
|
||||||
const memberIds = new Set(
|
|
||||||
this.roomMembers()
|
|
||||||
.map((member) => this.roomMemberKey(member))
|
|
||||||
.filter(Boolean)
|
|
||||||
);
|
|
||||||
const current = this.currentUser();
|
|
||||||
|
|
||||||
if (current) {
|
|
||||||
memberIds.add(current.oderId || current.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return memberIds.size;
|
|
||||||
});
|
|
||||||
|
|
||||||
showChannelMenu = signal(false);
|
|
||||||
channelMenuX = signal(0);
|
|
||||||
channelMenuY = signal(0);
|
|
||||||
contextChannel = signal<Channel | null>(null);
|
|
||||||
|
|
||||||
renamingChannelId = signal<string | null>(null);
|
|
||||||
|
|
||||||
showCreateChannelDialog = signal(false);
|
|
||||||
createChannelType = signal<'text' | 'voice'>('text');
|
|
||||||
newChannelName = '';
|
|
||||||
|
|
||||||
showUserMenu = signal(false);
|
|
||||||
userMenuX = signal(0);
|
|
||||||
userMenuY = signal(0);
|
|
||||||
contextMenuUser = signal<User | null>(null);
|
|
||||||
|
|
||||||
showVolumeMenu = signal(false);
|
|
||||||
volumeMenuX = signal(0);
|
|
||||||
volumeMenuY = signal(0);
|
|
||||||
volumeMenuPeerId = signal('');
|
|
||||||
volumeMenuDisplayName = signal('');
|
|
||||||
|
|
||||||
onlineUsersFiltered() {
|
|
||||||
const current = this.currentUser();
|
|
||||||
const currentId = current?.id;
|
|
||||||
const currentOderId = current?.oderId;
|
|
||||||
|
|
||||||
return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private roomMemberKey(member: RoomMember): string {
|
|
||||||
return member.oderId || member.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
canManageChannels(): boolean {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
const user = this.currentUser();
|
|
||||||
|
|
||||||
if (!room || !user)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (room.hostId === user.id)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
const perms = room.permissions || {};
|
|
||||||
|
|
||||||
if (user.role === 'admin' && perms.adminsManageRooms)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (user.role === 'moderator' && perms.moderatorsManageRooms)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectTextChannel(channelId: string) {
|
|
||||||
if (this.renamingChannelId())
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.voiceWorkspace.showChat();
|
|
||||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
|
||||||
}
|
|
||||||
|
|
||||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
|
||||||
evt.preventDefault();
|
|
||||||
this.contextChannel.set(channel);
|
|
||||||
this.channelMenuX.set(evt.clientX);
|
|
||||||
this.channelMenuY.set(evt.clientY);
|
|
||||||
this.showChannelMenu.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeChannelMenu() {
|
|
||||||
this.showChannelMenu.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
startRename() {
|
|
||||||
const ch = this.contextChannel();
|
|
||||||
|
|
||||||
this.closeChannelMenu();
|
|
||||||
|
|
||||||
if (ch) {
|
|
||||||
this.renamingChannelId.set(ch.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmRename(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const name = input.value.trim();
|
|
||||||
const channelId = this.renamingChannelId();
|
|
||||||
|
|
||||||
if (channelId && name) {
|
|
||||||
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.renamingChannelId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelRename() {
|
|
||||||
this.renamingChannelId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteChannel() {
|
|
||||||
const ch = this.contextChannel();
|
|
||||||
|
|
||||||
this.closeChannelMenu();
|
|
||||||
|
|
||||||
if (ch) {
|
|
||||||
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resyncMessages() {
|
|
||||||
this.closeChannelMenu();
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.store.dispatch(MessagesActions.startSync());
|
|
||||||
|
|
||||||
const peers = this.webrtc.getConnectedPeers();
|
|
||||||
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
|
||||||
|
|
||||||
peers.forEach((pid) => {
|
|
||||||
try {
|
|
||||||
this.webrtc.sendToPeer(pid, inventoryRequest);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createChannel(type: 'text' | 'voice') {
|
|
||||||
this.createChannelType.set(type);
|
|
||||||
this.newChannelName = '';
|
|
||||||
this.showCreateChannelDialog.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmCreateChannel() {
|
|
||||||
const name = this.newChannelName.trim();
|
|
||||||
|
|
||||||
if (!name)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const type = this.createChannelType();
|
|
||||||
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
|
|
||||||
const channel: Channel = {
|
|
||||||
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
position: existing.length
|
|
||||||
};
|
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.addChannel({ channel }));
|
|
||||||
this.showCreateChannelDialog.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelCreateChannel() {
|
|
||||||
this.showCreateChannelDialog.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
|
||||||
evt.preventDefault();
|
|
||||||
|
|
||||||
if (!this.isAdmin())
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.contextMenuUser.set(user);
|
|
||||||
this.userMenuX.set(evt.clientX);
|
|
||||||
this.userMenuY.set(evt.clientY);
|
|
||||||
this.showUserMenu.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeUserMenu() {
|
|
||||||
this.showUserMenu.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
|
|
||||||
evt.preventDefault();
|
|
||||||
const me = this.currentUser();
|
|
||||||
|
|
||||||
if (user.id === me?.id || user.oderId === me?.oderId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.volumeMenuPeerId.set(user.oderId || user.id);
|
|
||||||
this.volumeMenuDisplayName.set(user.displayName);
|
|
||||||
this.volumeMenuX.set(evt.clientX);
|
|
||||||
this.volumeMenuY.set(evt.clientY);
|
|
||||||
this.showVolumeMenu.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
|
||||||
const user = this.contextMenuUser();
|
|
||||||
const roomId = this.currentRoom()?.id;
|
|
||||||
|
|
||||||
this.closeUserMenu();
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'role-change',
|
|
||||||
roomId,
|
|
||||||
targetUserId: user.id,
|
|
||||||
role
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kickUserAction() {
|
|
||||||
const user = this.contextMenuUser();
|
|
||||||
|
|
||||||
this.closeUserMenu();
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
joinVoice(roomId: string) {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
const current = this.currentUser();
|
|
||||||
|
|
||||||
if (
|
|
||||||
room
|
|
||||||
&& current?.voiceState?.isConnected
|
|
||||||
&& current.voiceState.roomId === roomId
|
|
||||||
&& current.voiceState.serverId === room.id
|
|
||||||
) {
|
|
||||||
this.voiceWorkspace.open(null, { connectRemoteShares: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!room)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
|
||||||
if (!this.webrtc.isVoiceConnected()) {
|
|
||||||
if (current.id) {
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: current.id,
|
|
||||||
voiceState: {
|
|
||||||
isConnected: false,
|
|
||||||
isMuted: false,
|
|
||||||
isDeafened: false,
|
|
||||||
roomId: undefined,
|
|
||||||
serverId: undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
|
||||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
|
||||||
|
|
||||||
enableVoicePromise
|
|
||||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
|
||||||
this.updateVoiceStateStore(roomId, room, current);
|
|
||||||
this.trackCurrentUserMic();
|
|
||||||
this.startVoiceHeartbeat(roomId, room);
|
|
||||||
this.broadcastVoiceConnected(roomId, room, current);
|
|
||||||
this.startVoiceSession(roomId, room);
|
|
||||||
}
|
|
||||||
|
|
||||||
private trackCurrentUserMic(): void {
|
|
||||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
|
||||||
const micStream = this.webrtc.getRawMicStream();
|
|
||||||
|
|
||||||
if (userId && micStream) {
|
|
||||||
this.voiceActivity.trackLocalMic(userId, micStream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private untrackCurrentUserMic(): void {
|
|
||||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
this.voiceActivity.untrackLocalMic(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
|
||||||
if (!current?.id)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: current.id,
|
|
||||||
voiceState: {
|
|
||||||
isConnected: true,
|
|
||||||
isMuted: current.voiceState?.isMuted ?? false,
|
|
||||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
|
||||||
roomId,
|
|
||||||
serverId: room.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
|
||||||
this.webrtc.startVoiceHeartbeat(roomId, room.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'voice-state',
|
|
||||||
oderId: current?.oderId || current?.id,
|
|
||||||
displayName: current?.displayName || 'User',
|
|
||||||
voiceState: {
|
|
||||||
isConnected: true,
|
|
||||||
isMuted: current?.voiceState?.isMuted ?? false,
|
|
||||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
|
||||||
roomId,
|
|
||||||
serverId: room.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private startVoiceSession(roomId: string, room: Room): void {
|
|
||||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
|
||||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
|
||||||
|
|
||||||
this.voiceSessionService.startSession({
|
|
||||||
serverId: room.id,
|
|
||||||
serverName: room.name,
|
|
||||||
roomId,
|
|
||||||
roomName: voiceRoomName,
|
|
||||||
serverIcon: room.icon,
|
|
||||||
serverDescription: room.description,
|
|
||||||
serverRoute: `/room/${room.id}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
leaveVoice(roomId: string) {
|
|
||||||
const current = this.currentUser();
|
|
||||||
|
|
||||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.webrtc.stopVoiceHeartbeat();
|
|
||||||
|
|
||||||
this.untrackCurrentUserMic();
|
|
||||||
|
|
||||||
this.webrtc.disableVoice();
|
|
||||||
|
|
||||||
if (current?.id) {
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: current.id,
|
|
||||||
voiceState: {
|
|
||||||
isConnected: false,
|
|
||||||
isMuted: false,
|
|
||||||
isDeafened: false,
|
|
||||||
roomId: undefined,
|
|
||||||
serverId: undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'voice-state',
|
|
||||||
oderId: current?.oderId || current?.id,
|
|
||||||
displayName: current?.displayName || 'User',
|
|
||||||
voiceState: {
|
|
||||||
isConnected: false,
|
|
||||||
isMuted: false,
|
|
||||||
isDeafened: false,
|
|
||||||
roomId: undefined,
|
|
||||||
serverId: undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.voiceSessionService.endSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
voiceOccupancy(roomId: string): number {
|
|
||||||
return this.voiceUsersInRoom(roomId).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
viewShare(userId: string) {
|
|
||||||
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
viewStream(userId: string) {
|
|
||||||
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
isUserLocallyMuted(user: User): boolean {
|
|
||||||
const peerId = user.oderId || user.id;
|
|
||||||
|
|
||||||
return this.voicePlayback.isUserMuted(peerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
isUserSharing(userId: string): boolean {
|
|
||||||
const me = this.currentUser();
|
|
||||||
|
|
||||||
if (me?.id === userId) {
|
|
||||||
return this.webrtc.isScreenSharing();
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
|
|
||||||
|
|
||||||
if (user?.screenShareState?.isSharing === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerKeys = [
|
|
||||||
user?.oderId,
|
|
||||||
user?.id,
|
|
||||||
userId
|
|
||||||
].filter(
|
|
||||||
(candidate): candidate is string => !!candidate
|
|
||||||
);
|
|
||||||
const stream = peerKeys
|
|
||||||
.map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey))
|
|
||||||
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
|
|
||||||
|
|
||||||
return !!stream && stream.getVideoTracks().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
voiceUsersInRoom(roomId: string) {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
const me = this.currentUser();
|
|
||||||
const remoteUsers = this.onlineUsers().filter(
|
|
||||||
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
me?.voiceState?.isConnected &&
|
|
||||||
me.voiceState?.roomId === roomId &&
|
|
||||||
me.voiceState?.serverId === room?.id
|
|
||||||
) {
|
|
||||||
const meId = me.id;
|
|
||||||
const meOderId = me.oderId;
|
|
||||||
const alreadyIncluded = remoteUsers.some(
|
|
||||||
(user) => user.id === meId || user.oderId === meOderId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!alreadyIncluded) {
|
|
||||||
return [me, ...remoteUsers];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return remoteUsers;
|
|
||||||
}
|
|
||||||
|
|
||||||
isCurrentRoom(roomId: string): boolean {
|
|
||||||
const me = this.currentUser();
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
voiceEnabled(): boolean {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
return room?.permissions?.allowVoice !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPeerLatency(user: User): number | null {
|
|
||||||
const latencies = this.webrtc.peerLatencies();
|
|
||||||
|
|
||||||
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPingColorClass(user: User): string {
|
|
||||||
const ms = this.getPeerLatency(user);
|
|
||||||
|
|
||||||
if (ms === null)
|
|
||||||
return 'bg-gray-500';
|
|
||||||
|
|
||||||
if (ms < 100)
|
|
||||||
return 'bg-green-500';
|
|
||||||
|
|
||||||
if (ms < 200)
|
|
||||||
return 'bg-yellow-500';
|
|
||||||
|
|
||||||
if (ms < 350)
|
|
||||||
return 'bg-orange-500';
|
|
||||||
|
|
||||||
return 'bg-red-500';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
|
||||||
<!-- Create button -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
||||||
title="Create Server"
|
|
||||||
(click)="createServer()"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucidePlus"
|
|
||||||
class="w-5 h-5"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Saved servers icons -->
|
|
||||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
|
||||||
@for (room of visibleSavedRooms(); track room.id) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
|
||||||
[title]="room.name"
|
|
||||||
(click)="joinSavedRoom(room)"
|
|
||||||
(contextmenu)="openContextMenu($event, room)"
|
|
||||||
>
|
|
||||||
@if (room.icon) {
|
|
||||||
<img
|
|
||||||
[ngSrc]="room.icon"
|
|
||||||
[alt]="room.name"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
|
||||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Context menu -->
|
|
||||||
@if (showMenu()) {
|
|
||||||
<app-context-menu
|
|
||||||
[x]="menuX()"
|
|
||||||
[y]="menuY()"
|
|
||||||
(closed)="closeMenu()"
|
|
||||||
[width]="'w-44'"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="openLeaveConfirm()"
|
|
||||||
class="context-menu-item"
|
|
||||||
>
|
|
||||||
Leave Server
|
|
||||||
</button>
|
|
||||||
</app-context-menu>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showBannedDialog()) {
|
|
||||||
<app-confirm-dialog
|
|
||||||
title="Banned"
|
|
||||||
confirmLabel="OK"
|
|
||||||
cancelLabel="Close"
|
|
||||||
variant="danger"
|
|
||||||
[widthClass]="'w-96 max-w-[90vw]'"
|
|
||||||
(confirmed)="closeBannedDialog()"
|
|
||||||
(cancelled)="closeBannedDialog()"
|
|
||||||
>
|
|
||||||
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
|
||||||
</app-confirm-dialog>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showPasswordDialog() && passwordPromptRoom()) {
|
|
||||||
<app-confirm-dialog
|
|
||||||
title="Password required"
|
|
||||||
confirmLabel="Join server"
|
|
||||||
cancelLabel="Cancel"
|
|
||||||
[widthClass]="'w-[420px] max-w-[92vw]'"
|
|
||||||
(confirmed)="confirmPasswordJoin()"
|
|
||||||
(cancelled)="closePasswordDialog()"
|
|
||||||
>
|
|
||||||
<div class="space-y-3 text-left">
|
|
||||||
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="rail-join-password"
|
|
||||||
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
||||||
>
|
|
||||||
Server password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="rail-join-password"
|
|
||||||
type="password"
|
|
||||||
[(ngModel)]="joinPassword"
|
|
||||||
placeholder="Enter password"
|
|
||||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (joinPasswordError()) {
|
|
||||||
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</app-confirm-dialog>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showLeaveConfirm() && contextRoom()) {
|
|
||||||
<app-leave-server-dialog
|
|
||||||
[room]="contextRoom()!"
|
|
||||||
[currentUser]="currentUser() ?? null"
|
|
||||||
(confirmed)="confirmLeave($event)"
|
|
||||||
(cancelled)="cancelLeave()"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<div class="space-y-6 max-w-xl">
|
|
||||||
<section>
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<ng-icon
|
|
||||||
name="lucidePower"
|
|
||||||
class="w-5 h-5 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<h4 class="text-sm font-semibold text-foreground">Application</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
|
||||||
[class.opacity-60]="!isElectron"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
|
|
||||||
|
|
||||||
@if (isElectron) {
|
|
||||||
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
|
|
||||||
} @else {
|
|
||||||
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
class="relative inline-flex items-center"
|
|
||||||
[class.cursor-pointer]="isElectron && !savingAutoStart()"
|
|
||||||
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[checked]="autoStart()"
|
|
||||||
[disabled]="!isElectron || savingAutoStart()"
|
|
||||||
(change)="onAutoStartChange($event)"
|
|
||||||
id="general-auto-start-toggle"
|
|
||||||
aria-label="Toggle launch on startup"
|
|
||||||
class="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
|
||||||
></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import { lucidePower } from '@ng-icons/lucide';
|
|
||||||
|
|
||||||
import { PlatformService } from '../../../../core/services/platform.service';
|
|
||||||
|
|
||||||
interface DesktopSettingsSnapshot {
|
|
||||||
autoStart: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeneralSettingsElectronApi {
|
|
||||||
getDesktopSettings?: () => Promise<DesktopSettingsSnapshot>;
|
|
||||||
setDesktopSettings?: (patch: { autoStart?: boolean }) => Promise<DesktopSettingsSnapshot>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeneralSettingsWindow = Window & {
|
|
||||||
electronAPI?: GeneralSettingsElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-general-settings',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, NgIcon],
|
|
||||||
viewProviders: [
|
|
||||||
provideIcons({
|
|
||||||
lucidePower
|
|
||||||
})
|
|
||||||
],
|
|
||||||
templateUrl: './general-settings.component.html'
|
|
||||||
})
|
|
||||||
export class GeneralSettingsComponent {
|
|
||||||
private platform = inject(PlatformService);
|
|
||||||
|
|
||||||
readonly isElectron = this.platform.isElectron;
|
|
||||||
autoStart = signal(false);
|
|
||||||
savingAutoStart = signal(false);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (this.isElectron) {
|
|
||||||
void this.loadDesktopSettings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onAutoStartChange(event: Event): Promise<void> {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const enabled = !!input.checked;
|
|
||||||
const api = this.getElectronApi();
|
|
||||||
|
|
||||||
if (!this.isElectron || !api?.setDesktopSettings) {
|
|
||||||
input.checked = this.autoStart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.savingAutoStart.set(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snapshot = await api.setDesktopSettings({ autoStart: enabled });
|
|
||||||
|
|
||||||
this.autoStart.set(snapshot.autoStart);
|
|
||||||
} catch {
|
|
||||||
input.checked = this.autoStart();
|
|
||||||
} finally {
|
|
||||||
this.savingAutoStart.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadDesktopSettings(): Promise<void> {
|
|
||||||
const api = this.getElectronApi();
|
|
||||||
|
|
||||||
if (!api?.getDesktopSettings) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snapshot = await api.getDesktopSettings();
|
|
||||||
|
|
||||||
this.autoStart.set(snapshot.autoStart);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getElectronApi(): GeneralSettingsElectronApi | null {
|
|
||||||
return typeof window !== 'undefined'
|
|
||||||
? (window as GeneralSettingsWindow).electronAPI ?? null
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
@if (server()) {
|
|
||||||
<div class="space-y-3 max-w-xl">
|
|
||||||
@if (members().length === 0) {
|
|
||||||
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
|
|
||||||
} @else {
|
|
||||||
@for (member of members(); track member.oderId || member.id) {
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<app-user-avatar
|
|
||||||
[name]="member.displayName || '?'"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<p class="text-sm font-medium text-foreground truncate">
|
|
||||||
{{ member.displayName }}
|
|
||||||
</p>
|
|
||||||
@if (member.isOnline) {
|
|
||||||
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span>
|
|
||||||
}
|
|
||||||
@if (member.role === 'host') {
|
|
||||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
|
||||||
} @else if (member.role === 'admin') {
|
|
||||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
|
||||||
} @else if (member.role === 'moderator') {
|
|
||||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (member.role !== 'host' && isAdmin()) {
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
@if (canChangeRoles()) {
|
|
||||||
<select
|
|
||||||
[ngModel]="member.role"
|
|
||||||
(ngModelChange)="changeRole(member, $event)"
|
|
||||||
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="member">Member</option>
|
|
||||||
<option value="moderator">Moderator</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
}
|
|
||||||
@if (canKickMembers()) {
|
|
||||||
<button
|
|
||||||
(click)="kickMember(member)"
|
|
||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Kick"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideUserX"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (canBanMembers()) {
|
|
||||||
<button
|
|
||||||
(click)="banMember(member)"
|
|
||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Ban"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideBan"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
@if (server()) {
|
|
||||||
<div class="space-y-4 max-w-xl">
|
|
||||||
@if (!isAdmin()) {
|
|
||||||
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
|
|
||||||
}
|
|
||||||
<div class="space-y-2.5">
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="allowVoice"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Users can share their screen</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="allowScreenShare"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Users can upload files</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="allowFileUploads"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Slow Mode</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Limit message frequency</p>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
[(ngModel)]="slowModeInterval"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="0">Off</option>
|
|
||||||
<option value="5">5 seconds</option>
|
|
||||||
<option value="10">10 seconds</option>
|
|
||||||
<option value="30">30 seconds</option>
|
|
||||||
<option value="60">1 minute</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Management permissions -->
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Allow admins to create/modify rooms</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="adminsManageRooms"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Allow moderators to create/modify rooms</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="moderatorsManageRooms"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="adminsManageIcon"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[(ngModel)]="moderatorsManageIcon"
|
|
||||||
[disabled]="!isAdmin()"
|
|
||||||
class="w-4 h-4 accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (isAdmin()) {
|
|
||||||
<button
|
|
||||||
(click)="savePermissions()"
|
|
||||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm"
|
|
||||||
[class.bg-green-600]="saveSuccess() === 'permissions'"
|
|
||||||
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideCheck"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { lucideCheck } from '@ng-icons/lucide';
|
|
||||||
|
|
||||||
import { Room } from '../../../../core/models/index';
|
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-permissions-settings',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
NgIcon
|
|
||||||
],
|
|
||||||
viewProviders: [
|
|
||||||
provideIcons({
|
|
||||||
lucideCheck
|
|
||||||
})
|
|
||||||
],
|
|
||||||
templateUrl: './permissions-settings.component.html'
|
|
||||||
})
|
|
||||||
export class PermissionsSettingsComponent {
|
|
||||||
private store = inject(Store);
|
|
||||||
|
|
||||||
/** The currently selected server, passed from the parent. */
|
|
||||||
server = input<Room | null>(null);
|
|
||||||
/** Whether the current user is admin of this server. */
|
|
||||||
isAdmin = input(false);
|
|
||||||
|
|
||||||
allowVoice = true;
|
|
||||||
allowScreenShare = true;
|
|
||||||
allowFileUploads = true;
|
|
||||||
slowModeInterval = '0';
|
|
||||||
adminsManageRooms = false;
|
|
||||||
moderatorsManageRooms = false;
|
|
||||||
adminsManageIcon = false;
|
|
||||||
moderatorsManageIcon = false;
|
|
||||||
|
|
||||||
saveSuccess = signal<string | null>(null);
|
|
||||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
/** Load permissions from the server input. Called by parent via effect or on init. */
|
|
||||||
loadPermissions(room: Room): void {
|
|
||||||
const perms = room.permissions || {};
|
|
||||||
|
|
||||||
this.allowVoice = perms.allowVoice !== false;
|
|
||||||
this.allowScreenShare = perms.allowScreenShare !== false;
|
|
||||||
this.allowFileUploads = perms.allowFileUploads !== false;
|
|
||||||
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
|
|
||||||
this.adminsManageRooms = !!perms.adminsManageRooms;
|
|
||||||
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
|
|
||||||
this.adminsManageIcon = !!perms.adminsManageIcon;
|
|
||||||
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
savePermissions(): void {
|
|
||||||
const room = this.server();
|
|
||||||
|
|
||||||
if (!room)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.updateRoomPermissions({
|
|
||||||
roomId: room.id,
|
|
||||||
permissions: {
|
|
||||||
allowVoice: this.allowVoice,
|
|
||||||
allowScreenShare: this.allowScreenShare,
|
|
||||||
allowFileUploads: this.allowFileUploads,
|
|
||||||
slowModeInterval: parseInt(this.slowModeInterval, 10),
|
|
||||||
adminsManageRooms: this.adminsManageRooms,
|
|
||||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
|
||||||
adminsManageIcon: this.adminsManageIcon,
|
|
||||||
moderatorsManageIcon: this.moderatorsManageIcon
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.showSaveSuccess('permissions');
|
|
||||||
}
|
|
||||||
|
|
||||||
private showSaveSuccess(key: string): void {
|
|
||||||
this.saveSuccess.set(key);
|
|
||||||
|
|
||||||
if (this.saveTimeout)
|
|
||||||
clearTimeout(this.saveTimeout);
|
|
||||||
|
|
||||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user