Compare commits
1 Commits
v1.0.88
...
6dff163b88
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dff163b88 |
@@ -67,10 +67,8 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
|
||||
cd toju-app
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
||||
npx ng build --configuration production --base-href='./'
|
||||
cd ..
|
||||
npx --package typescript tsc -p tsconfig.electron.json
|
||||
cd server
|
||||
node ../tools/sync-server-build-version.js
|
||||
@@ -122,10 +120,8 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
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 esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
||||
npx ng build --configuration production --base-href='./'
|
||||
Pop-Location
|
||||
npx --package typescript tsc -p tsconfig.electron.json
|
||||
Push-Location server
|
||||
node ../tools/sync-server-build-version.js
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,9 +6,7 @@
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
*.sqlite
|
||||
*/architecture.md
|
||||
/docs
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
@@ -53,6 +51,3 @@ Thumbs.db
|
||||
.certs/
|
||||
/server/data/variables.json
|
||||
dist-server/*
|
||||
|
||||
AGENTS.md
|
||||
doc/**
|
||||
|
||||
73
README.md
73
README.md
@@ -1,14 +1,16 @@
|
||||
<img src="./images/icon.png" width="100" height="100">
|
||||
|
||||
|
||||
# Toju / Zoracord
|
||||
|
||||
Desktop chat app with four parts:
|
||||
Desktop chat app with three parts:
|
||||
|
||||
- `src/` Angular client
|
||||
- `electron/` desktop shell, IPC, and local database
|
||||
- `server/` directory server, join request API, and websocket events
|
||||
- `website/` Toju website served at toju.app
|
||||
|
||||
## Architecture
|
||||
|
||||
- Renderer architecture and refactor conventions live in `docs/architecture.md`
|
||||
- Electron renderer integrations should go through `src/app/core/platform/electron/`
|
||||
- Pure shared logic belongs in `src/app/core/helpers/` and reusable contracts belong in `src/app/core/models/`
|
||||
|
||||
## Install
|
||||
|
||||
@@ -56,64 +58,3 @@ Inside `server/`:
|
||||
- `npm run dev` starts the server with reload
|
||||
- `npm run build` compiles to `dist/`
|
||||
- `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) |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "../node_modules/@angular/cli/lib/config/schema.json",
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
@@ -62,28 +62,27 @@
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss",
|
||||
"../node_modules/prismjs/themes/prism-okaidia.css"
|
||||
"node_modules/prismjs/themes/prism-okaidia.css"
|
||||
],
|
||||
"scripts": [
|
||||
"../node_modules/prismjs/prism.js",
|
||||
"../node_modules/prismjs/components/prism-markup.min.js",
|
||||
"../node_modules/prismjs/components/prism-clike.min.js",
|
||||
"../node_modules/prismjs/components/prism-javascript.min.js",
|
||||
"../node_modules/prismjs/components/prism-typescript.min.js",
|
||||
"../node_modules/prismjs/components/prism-css.min.js",
|
||||
"../node_modules/prismjs/components/prism-scss.min.js",
|
||||
"../node_modules/prismjs/components/prism-json.min.js",
|
||||
"../node_modules/prismjs/components/prism-bash.min.js",
|
||||
"../node_modules/prismjs/components/prism-markdown.min.js",
|
||||
"../node_modules/prismjs/components/prism-yaml.min.js",
|
||||
"../node_modules/prismjs/components/prism-python.min.js",
|
||||
"../node_modules/prismjs/components/prism-csharp.min.js"
|
||||
"node_modules/prismjs/prism.js",
|
||||
"node_modules/prismjs/components/prism-markup.min.js",
|
||||
"node_modules/prismjs/components/prism-clike.min.js",
|
||||
"node_modules/prismjs/components/prism-javascript.min.js",
|
||||
"node_modules/prismjs/components/prism-typescript.min.js",
|
||||
"node_modules/prismjs/components/prism-css.min.js",
|
||||
"node_modules/prismjs/components/prism-scss.min.js",
|
||||
"node_modules/prismjs/components/prism-json.min.js",
|
||||
"node_modules/prismjs/components/prism-bash.min.js",
|
||||
"node_modules/prismjs/components/prism-markdown.min.js",
|
||||
"node_modules/prismjs/components/prism-yaml.min.js",
|
||||
"node_modules/prismjs/components/prism-python.min.js",
|
||||
"node_modules/prismjs/components/prism-csharp.min.js"
|
||||
],
|
||||
"allowedCommonJsDependencies": [
|
||||
"simple-peer",
|
||||
"uuid"
|
||||
],
|
||||
"outputPath": "../dist/client"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -96,8 +95,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.3MB"
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
4
dev.sh
4
dev.sh
@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
|
||||
"$DIR/generate-cert.sh"
|
||||
fi
|
||||
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
|
||||
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
|
||||
WAIT_URL="https://localhost:4200"
|
||||
HEALTH_URL="https://localhost:3001/api/health"
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
else
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
||||
NG_SERVE="ng serve --host=0.0.0.0"
|
||||
WAIT_URL="http://localhost:4200"
|
||||
HEALTH_URL="http://localhost:3001/api/health"
|
||||
fi
|
||||
|
||||
166
docs/architecture.md
Normal file
166
docs/architecture.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Frontend Architecture
|
||||
|
||||
This document defines the target structure for the Angular renderer and the boundaries between web-safe code, Electron adapters, reusable logic, and feature UI.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep feature code easy to navigate by grouping it around user-facing domains.
|
||||
- Push platform-specific behavior behind explicit adapters.
|
||||
- Move pure logic into helpers and dedicated model files instead of embedding it in components and large services.
|
||||
- Split large services into smaller units with one clear responsibility each.
|
||||
|
||||
## Target Structure
|
||||
|
||||
```text
|
||||
src/app/
|
||||
app.ts
|
||||
domains/
|
||||
<domain>/
|
||||
application/ # facades and use-case orchestration
|
||||
domain/ # models and pure domain logic
|
||||
infrastructure/ # adapters, api clients, persistence
|
||||
feature/ # smart domain UI containers
|
||||
ui/ # dumb/presentational domain UI
|
||||
infrastructure/
|
||||
persistence/ # shared storage adapters and persistence facades
|
||||
realtime/ # shared signaling, peer transport, media runtime state
|
||||
shared-kernel/ # intentionally shared cross-domain contracts and enums
|
||||
core/
|
||||
constants.ts # cross-domain technical constants only
|
||||
helpers/ # transitional pure helpers still being moved out
|
||||
models/ # transitional shared contracts pending extraction
|
||||
platform/
|
||||
platform.service.ts # runtime/environment detection adapters
|
||||
external-link.service.ts # browser/electron navigation adapters
|
||||
electron/ # renderer-side adapters for preload / Electron APIs
|
||||
realtime/ # compatibility/public import boundary for realtime
|
||||
services/ # technical cross-domain services only
|
||||
features/ # transitional feature area while slices move into domains/*/feature
|
||||
shared/
|
||||
ui/ # shared presentational primitives
|
||||
utils/ # shared pure utilities
|
||||
store/ # ngrx reducers, effects, selectors, actions
|
||||
```
|
||||
|
||||
## Layering Rules
|
||||
|
||||
Dependency direction must stay one-way:
|
||||
|
||||
1. `domains/*/feature`, `domains/*/ui`, and transitional `features/` may depend on `domains/`, `infrastructure/`, `core/`, `shared/`, and `store/`.
|
||||
2. `domains/*/application` may depend on its own `domain/`, `infrastructure/`, top-level `infrastructure/`, `core/`, and `store/`.
|
||||
3. `domains/*/domain` should stay framework-light and hold domain models plus pure logic.
|
||||
4. `domains/*/infrastructure` may depend on `core/platform/`, browser APIs, HTTP, persistence, and external adapters.
|
||||
5. Top-level `infrastructure/` should hold shared technical runtime implementations; `core/` should hold cross-domain utilities, compatibility entry points, and platform adapters rather than full runtime subsystems.
|
||||
6. `shared-kernel/` should hold the intentionally shared cross-domain contracts that more than one domain must agree on.
|
||||
7. `core/models/` is transitional and should shrink over time. Domain-specific models belong inside their domain folder, while stable cross-domain contracts should move into `shared-kernel/`.
|
||||
|
||||
## Responsibility Split
|
||||
|
||||
Use these roles consistently when a domain starts to absorb too many concerns:
|
||||
|
||||
- `application`: Angular-facing facades and use-case orchestration.
|
||||
- `domain`: contracts, policies, calculations, mapping rules, and other pure domain logic.
|
||||
- `infrastructure`: platform adapters, storage, transport, HTTP, IPC, and persistence boundaries.
|
||||
- `feature`: smart components that wire store, routing, and facades.
|
||||
- `ui`: presentational components with minimal business knowledge.
|
||||
|
||||
## Platform Boundary
|
||||
|
||||
Renderer code should not access `window.electronAPI` directly except inside the Electron adapter layer.
|
||||
|
||||
Current convention:
|
||||
|
||||
- Use `core/platform/electron/electron-api.models.ts` for Angular-side Electron typings.
|
||||
- Use `core/platform/electron/electron-bridge.service.ts` to reach preload APIs from Angular code.
|
||||
- Keep runtime detection and browser/Electron wrappers such as `core/platform/platform.service.ts` and `core/platform/external-link.service.ts` inside `core/platform/` rather than `core/services/`.
|
||||
- Keep Electron-only persistence and file-system logic inside dedicated adapter services such as `domains/attachment/infrastructure/attachment-storage.service.ts`.
|
||||
|
||||
This keeps feature and domain code platform-agnostic and makes the browser runtime easier to reason about.
|
||||
|
||||
## Models And Pure Logic
|
||||
|
||||
- Attachment runtime types now live in `domains/attachment/domain/attachment.models.ts`.
|
||||
- Attachment download-policy rules now live in `domains/attachment/domain/attachment.logic.ts`.
|
||||
- Attachment file-path sanitizing and storage-bucket selection live in `domains/attachment/infrastructure/attachment-storage.helpers.ts`.
|
||||
- New domain types should be placed in a dedicated model file inside their domain folder when they are not shared across domains.
|
||||
- Only transitional shared contracts should stay in `core/models/`.
|
||||
- Stable cross-domain enums and contracts should be extracted into `shared-kernel/` once ownership is clear.
|
||||
- Shared signaling and attachment transport contracts should live in `shared-kernel/` rather than inside `core/models/`.
|
||||
|
||||
## Incremental Refactor Path
|
||||
|
||||
The repo is large enough that refactoring must stay incremental. Preferred order:
|
||||
|
||||
1. Extract platform adapters from direct renderer global access.
|
||||
2. Split persistence and cache behavior out of large orchestration services.
|
||||
3. Move reusable types and helper logic into dedicated files.
|
||||
4. Break remaining multi-responsibility facades into managers or coordinators.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
The current refactor establishes these patterns:
|
||||
|
||||
- Shared Electron bridge for Angular services and root app wiring.
|
||||
- Attachment now lives under `domains/attachment/` with `application/attachment.facade.ts` as the public Angular-facing boundary.
|
||||
- Attachment application orchestration is now split across `domains/attachment/application/attachment-manager.service.ts`, `attachment-transfer.service.ts`, `attachment-persistence.service.ts`, and `attachment-runtime.store.ts`.
|
||||
- Attachment runtime types and pure transfer-policy logic live in `domains/attachment/domain/`.
|
||||
- Attachment disk persistence and storage helpers live in `domains/attachment/infrastructure/`.
|
||||
- Shared browser/Electron persistence adapters now live in `infrastructure/persistence/`, with `DatabaseService` as the renderer-facing facade over IndexedDB and Electron CQRS-backed SQLite.
|
||||
- Auth HTTP/login orchestration now lives under `domains/auth/application/auth.service.ts`.
|
||||
- Chat GIF search and KLIPY integration now live under `domains/chat/application/klipy.service.ts`.
|
||||
- Voice-session now lives under `domains/voice-session/` with `application/voice-session.facade.ts` as the public Angular-facing boundary.
|
||||
- Voice-session models and pure route/room mapping logic live in `domains/voice-session/domain/`.
|
||||
- Voice workspace UI state now lives in `domains/voice-session/application/voice-workspace.service.ts`, and persisted voice/screen-share preferences now live in `domains/voice-session/infrastructure/voice-settings.storage.ts`.
|
||||
- Voice activity tracking now lives in `domains/voice-connection/application/voice-activity.service.ts`.
|
||||
- Server-directory now lives under `domains/server-directory/` with `application/server-directory.facade.ts` as the public Angular-facing boundary.
|
||||
- Server-directory endpoint state now lives in `domains/server-directory/application/server-endpoint-state.service.ts`.
|
||||
- Server-directory contracts and user-facing compatibility messaging live in `domains/server-directory/domain/`.
|
||||
- Endpoint default URL normalization and built-in endpoint templates live in `domains/server-directory/domain/server-endpoint-defaults.ts`.
|
||||
- Endpoint localStorage persistence, HTTP fan-out, compatibility checks, and health probes live in `domains/server-directory/infrastructure/`.
|
||||
- `infrastructure/realtime/realtime-session.service.ts` now holds the shared realtime runtime service.
|
||||
- `core/realtime/index.ts` is the compatibility/public import surface and re-exports that runtime service as `RealtimeSessionFacade` for technical cross-domain consumers.
|
||||
- `domains/voice-connection/` now exposes `application/voice-connection.facade.ts` as the voice-specific Angular-facing boundary.
|
||||
- `domains/screen-share/` now exposes `application/screen-share.facade.ts` plus `application/screen-share-source-picker.service.ts`, and `domain/screen-share.config.ts` is the real source for screen-share presets/options plus `ELECTRON_ENTIRE_SCREEN_SOURCE_NAME`.
|
||||
- Shared transport contracts and the low-level signaling and peer-connection stack now live under `infrastructure/realtime/`.
|
||||
- Shared media preference contracts now live under `shared-kernel/media-preferences.ts`, so `voice-session` settings no longer import `voice-connection` or `screen-share` domain internals.
|
||||
- Shared signaling and attachment contracts now live under `shared-kernel/signaling-contracts.ts` and `shared-kernel/attachment-contracts.ts`, while `core/models/` keeps compatibility re-exports during the migration.
|
||||
- Realtime debug network metric collection now lives in `infrastructure/realtime/logging/debug-network-metrics.ts`; the debug console reads that infrastructure state rather than keeping the metric store inside `core/services/`.
|
||||
- Shared media handling, voice orchestration helpers, noise reduction, and screen-share capture adapters now live in `infrastructure/realtime/media/`.
|
||||
- The old `domains/webrtc/*` and `core/services/webrtc.service.ts` compatibility shims have been removed after all in-repo callers moved to `core/realtime`, `domains/voice-connection/`, and `domains/screen-share/`.
|
||||
- Multi-server signaling topology and signaling-manager lifecycle extracted from `WebRTCService` into `ServerSignalingCoordinator`.
|
||||
- Angular-facing signal and connection-state bookkeeping extracted from `WebRTCService` into `WebRtcStateController`.
|
||||
- Peer/media event streams, peer messaging, remote stream access, and screen-share entry points extracted from `WebRTCService` into `PeerMediaFacade`.
|
||||
- Lower-level signaling connect/send/identify helpers extracted from `WebRTCService` into `SignalingTransportHandler`.
|
||||
- Incoming signaling message semantics extracted from `WebRTCService` into `IncomingSignalingMessageHandler`.
|
||||
- Outbound server-membership signaling commands extracted from `WebRTCService` into `ServerMembershipSignalingHandler`.
|
||||
- Remote screen-share request state and control-plane handling extracted from `WebRTCService` into `RemoteScreenShareRequestController`.
|
||||
- Voice session and heartbeat orchestration extracted from `WebRTCService` into `VoiceSessionController`.
|
||||
- Desktop client-version lookup and endpoint compatibility checks for server-directory live in `domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts`.
|
||||
- Endpoint health probing and fallback transport for server-directory live in `domains/server-directory/infrastructure/server-endpoint-health.service.ts`.
|
||||
- Server-directory HTTP fan-out, endpoint resolution, and API response normalization live in `domains/server-directory/infrastructure/server-directory-api.service.ts`.
|
||||
|
||||
## Shared Kernel Organisation
|
||||
|
||||
`shared-kernel/` is organised by business concept so newcomers can find types
|
||||
by meaning rather than by historical accident:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `user.models.ts` | `User`, `UserStatus`, `UserRole`, `RoomMember` |
|
||||
| `room.models.ts` | `Room`, `RoomSettings`, `RoomPermissions`, `Channel`, `ChannelType` |
|
||||
| `message.models.ts` | `Message`, `Reaction`, `DELETED_MESSAGE_CONTENT` |
|
||||
| `moderation.models.ts` | `BanEntry` |
|
||||
| `voice-state.models.ts` | `VoiceState`, `ScreenShareState` |
|
||||
| `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` |
|
||||
| `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets |
|
||||
| `signaling-contracts.ts` | `SignalingMessage`, `SignalingMessageType` |
|
||||
| `attachment-contracts.ts` | `ChatAttachmentAnnouncement`, `ChatAttachmentMeta` |
|
||||
|
||||
`core/models/index.ts` is now a **pure re-export barrel** that re-exports all
|
||||
of the above (plus domain re-exports like `ServerInfo`) for backward
|
||||
compatibility. New code should import from `shared-kernel/` directly.
|
||||
|
||||
## Next Candidates
|
||||
|
||||
- Migrate remaining voice and room UI from transitional `features/` into domain-local `feature/` and `ui/` folders.
|
||||
- Migrate remaining WebRTC-facing UI from transitional `features/` and `shared/` locations into domain-local `feature/` and `ui/` folders where it improves ownership.
|
||||
@@ -7,13 +7,7 @@ import {
|
||||
destroyDatabase,
|
||||
getDataSource
|
||||
} from '../db/database';
|
||||
import {
|
||||
createWindow,
|
||||
getDockIconPath,
|
||||
getMainWindow,
|
||||
prepareWindowForAppQuit,
|
||||
showMainWindow
|
||||
} from '../window/create-window';
|
||||
import { createWindow, getDockIconPath } from '../window/create-window';
|
||||
import {
|
||||
setupCqrsHandlers,
|
||||
setupSystemHandlers,
|
||||
@@ -36,13 +30,8 @@ export function registerAppLifecycle(): void {
|
||||
await createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (getMainWindow()) {
|
||||
void showMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (BrowserWindow.getAllWindows().length === 0)
|
||||
void createWindow();
|
||||
createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +41,6 @@ export function registerAppLifecycle(): void {
|
||||
});
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
shutdownDesktopUpdater();
|
||||
|
||||
@@ -3,11 +3,6 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -18,11 +13,6 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
||||
await dataSource.getRepository(MessageEntity).clear();
|
||||
await dataSource.getRepository(UserEntity).clear();
|
||||
await dataSource.getRepository(RoomEntity).clear();
|
||||
await dataSource.getRepository(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(BanEntity).clear();
|
||||
await dataSource.getRepository(AttachmentEntity).clear();
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
RoomChannelPermissionEntity,
|
||||
RoomChannelEntity,
|
||||
RoomEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
MessageEntity
|
||||
} from '../../../entities';
|
||||
import { RoomEntity, MessageEntity } from '../../../entities';
|
||||
import { DeleteRoomCommand } from '../../types';
|
||||
|
||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const { roomId } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
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 });
|
||||
});
|
||||
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
|
||||
await dataSource.getRepository(MessageEntity).delete({ roomId });
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { SaveMessageCommand } from '../../types';
|
||||
|
||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { message } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
@@ -17,11 +14,10 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
|
||||
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 replaceMessageReactions(manager, message.id, message.reactions ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { SaveRoomCommand } from '../../types';
|
||||
|
||||
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
||||
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
|
||||
return room.slowModeInterval;
|
||||
}
|
||||
|
||||
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 repo = dataSource.getRepository(RoomEntity);
|
||||
const { room } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(RoomEntity);
|
||||
const entity = repo.create({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
@@ -36,20 +19,13 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
slowModeInterval: extractSlowModeInterval(room),
|
||||
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);
|
||||
await replaceRoomRelations(manager, room.id, {
|
||||
channels: room.channels ?? [],
|
||||
members: room.members ?? [],
|
||||
roles: room.roles ?? [],
|
||||
roleAssignments: room.roleAssignments ?? [],
|
||||
channelPermissions: room.channelPermissions ?? [],
|
||||
permissions: room.permissions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { UpdateMessageCommand } from '../../types';
|
||||
|
||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { messageId, updates } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
|
||||
if (!existing)
|
||||
@@ -31,6 +28,9 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
||||
if (updates.editedAt !== undefined)
|
||||
existing.editedAt = updates.editedAt ?? null;
|
||||
|
||||
if (updates.reactions !== undefined)
|
||||
existing.reactions = JSON.stringify(updates.reactions ?? []);
|
||||
|
||||
if (updates.isDeleted !== undefined)
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
|
||||
@@ -38,9 +38,4 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
||||
existing.replyToId = updates.replyToId ?? null;
|
||||
|
||||
await repo.save(existing);
|
||||
|
||||
if (updates.reactions !== undefined) {
|
||||
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,68 +1,31 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { UpdateRoomCommand } from '../../types';
|
||||
import {
|
||||
applyUpdates,
|
||||
boolToInt,
|
||||
jsonOrNull,
|
||||
TransformMap
|
||||
} from './utils/applyUpdates';
|
||||
|
||||
const ROOM_TRANSFORMS: TransformMap = {
|
||||
hasPassword: boolToInt,
|
||||
isPrivate: boolToInt,
|
||||
userCount: (val) => (val ?? 0)
|
||||
userCount: (val) => (val ?? 0),
|
||||
permissions: jsonOrNull,
|
||||
channels: jsonOrNull,
|
||||
members: jsonOrNull
|
||||
};
|
||||
|
||||
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
|
||||
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
|
||||
return updates.slowModeInterval;
|
||||
}
|
||||
|
||||
const permissions = updates.permissions && typeof updates.permissions === 'object'
|
||||
? updates.permissions as { slowModeInterval?: unknown }
|
||||
: null;
|
||||
|
||||
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
|
||||
? permissions.slowModeInterval
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
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);
|
||||
applyUpdates(existing, updates, ROOM_TRANSFORMS);
|
||||
await repo.save(existing);
|
||||
await replaceRoomRelations(manager, roomId, {
|
||||
channels,
|
||||
members,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
permissions: rawPermissions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,19 +9,10 @@ import { RoomEntity } from '../entities/RoomEntity';
|
||||
import { ReactionEntity } from '../entities/ReactionEntity';
|
||||
import { BanEntity } from '../entities/BanEntity';
|
||||
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
||||
import { ReactionPayload } from './types';
|
||||
import {
|
||||
relationRecordToRoomPayload,
|
||||
RoomChannelPermissionRecord,
|
||||
RoomChannelRecord,
|
||||
RoomMemberRecord,
|
||||
RoomRoleAssignmentRecord,
|
||||
RoomRoleRecord
|
||||
} from './relations';
|
||||
|
||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
|
||||
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
|
||||
export function rowToMessage(row: MessageEntity) {
|
||||
const isDeleted = !!row.isDeleted;
|
||||
|
||||
return {
|
||||
@@ -33,7 +24,7 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
|
||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||
timestamp: row.timestamp,
|
||||
editedAt: row.editedAt ?? undefined,
|
||||
reactions: isDeleted ? [] : reactions,
|
||||
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
|
||||
isDeleted,
|
||||
replyToId: row.replyToId ?? undefined
|
||||
};
|
||||
@@ -58,30 +49,7 @@ export function rowToUser(row: UserEntity) {
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? []
|
||||
});
|
||||
|
||||
export function rowToRoom(row: RoomEntity) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -96,13 +64,9 @@ export function rowToRoom(
|
||||
maxUsers: row.maxUsers ?? undefined,
|
||||
icon: row.icon ?? undefined,
|
||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||
slowModeInterval: row.slowModeInterval,
|
||||
permissions: relationPayload.permissions,
|
||||
channels: relationPayload.channels,
|
||||
members: relationPayload.members,
|
||||
roles: relationPayload.roles,
|
||||
roleAssignments: relationPayload.roleAssignments,
|
||||
channelPermissions: relationPayload.channelPermissions,
|
||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||
members: row.members ? JSON.parse(row.members) : undefined,
|
||||
sourceId: row.sourceId ?? undefined,
|
||||
sourceName: row.sourceName ?? undefined,
|
||||
sourceUrl: row.sourceUrl ?? undefined
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const rows = await repo.find();
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||
return rows.map(rowToRoom);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,10 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessageByIdQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
|
||||
return row ? rowToMessage(row) : null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
@@ -13,7 +12,6 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
return rows.map(rowToMessage);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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,17 +2,10 @@ import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { GetRoomQuery } from '../../types';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToRoom(row, relationsByRoomId.get(row.id));
|
||||
return row ? rowToRoom(row) : null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
QueryTypeKey,
|
||||
Query,
|
||||
GetMessagesQuery,
|
||||
GetMessagesSinceQuery,
|
||||
GetMessageByIdQuery,
|
||||
GetReactionsForMessageQuery,
|
||||
GetUserQuery,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
GetAttachmentsForMessageQuery
|
||||
} from '../types';
|
||||
import { handleGetMessages } from './handlers/getMessages';
|
||||
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||
import { handleGetMessageById } from './handlers/getMessageById';
|
||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||
import { handleGetUser } from './handlers/getUser';
|
||||
@@ -29,7 +27,6 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
||||
|
||||
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
||||
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
|
||||
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
|
||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
||||
|
||||
export const QueryType = {
|
||||
GetMessages: 'get-messages',
|
||||
GetMessagesSince: 'get-messages-since',
|
||||
GetMessageById: 'get-message-by-id',
|
||||
GetReactionsForMessage: 'get-reactions-for-message',
|
||||
GetUser: 'get-user',
|
||||
@@ -61,44 +60,6 @@ export interface ReactionPayload {
|
||||
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 {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
@@ -130,13 +91,9 @@ export interface RoomPayload {
|
||||
maxUsers?: number;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
slowModeInterval?: number;
|
||||
permissions?: unknown;
|
||||
channels?: unknown[];
|
||||
members?: unknown[];
|
||||
roles?: AccessRolePayload[];
|
||||
roleAssignments?: RoleAssignmentPayload[];
|
||||
channelPermissions?: ChannelPermissionPayload[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
@@ -203,7 +160,6 @@ export type Command =
|
||||
| ClearAllDataCommand;
|
||||
|
||||
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 GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
|
||||
@@ -218,7 +174,6 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
|
||||
|
||||
export type Query =
|
||||
| GetMessagesQuery
|
||||
| GetMessagesSinceQuery
|
||||
| GetMessageByIdQuery
|
||||
| GetReactionsForMessageQuery
|
||||
| GetUserQuery
|
||||
|
||||
@@ -17,11 +17,6 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -43,11 +38,6 @@ export const AppDataSource = new DataSource({
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -45,11 +40,6 @@ export async function initializeDatabase(): Promise<void> {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
|
||||
@@ -7,7 +7,6 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
export interface DesktopSettings {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -22,7 +21,6 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoUpdateMode: 'auto',
|
||||
autoStart: true,
|
||||
closeToTray: true,
|
||||
hardwareAcceleration: true,
|
||||
manifestUrls: [],
|
||||
preferredVersion: null,
|
||||
@@ -88,9 +86,6 @@ export function readDesktopSettings(): DesktopSettings {
|
||||
autoStart: typeof parsed.autoStart === 'boolean'
|
||||
? parsed.autoStart
|
||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||
closeToTray: typeof parsed.closeToTray === 'boolean'
|
||||
? parsed.closeToTray
|
||||
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||
? parsed.vaapiVideoEncode
|
||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||
@@ -115,9 +110,6 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
||||
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||
? mergedSettings.autoStart
|
||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
|
||||
? mergedSettings.closeToTray
|
||||
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||
? mergedSettings.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||
|
||||
@@ -30,6 +30,9 @@ export class MessageEntity {
|
||||
@Column('integer', { nullable: true })
|
||||
editedAt!: number | null;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
reactions!: string;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isDeleted!: number;
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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,8 +45,14 @@ export class RoomEntity {
|
||||
@Column('integer', { nullable: true })
|
||||
iconUpdatedAt!: number | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
slowModeInterval!: number;
|
||||
@Column('text', { nullable: true })
|
||||
permissions!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
channels!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
members!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
sourceId!: string | null;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,11 +1,6 @@
|
||||
export { MessageEntity } from './MessageEntity';
|
||||
export { UserEntity } from './UserEntity';
|
||||
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 { BanEntity } from './BanEntity';
|
||||
export { AttachmentEntity } from './AttachmentEntity';
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Notification,
|
||||
shell
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
@@ -29,23 +28,10 @@ import {
|
||||
getDesktopUpdateState,
|
||||
handleDesktopSettingsChanged,
|
||||
restartToApplyUpdate,
|
||||
readDesktopUpdateServerHealth,
|
||||
type DesktopUpdateServerContext
|
||||
} from '../update/desktop-updater';
|
||||
import { consumePendingDeepLink } from '../app/deep-links';
|
||||
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 FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -99,12 +85,6 @@ interface ClipboardFilePayload {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface DesktopNotificationPayload {
|
||||
body: string;
|
||||
requestAttention?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function resolveLinuxDisplayServer(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
@@ -332,91 +312,11 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
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('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-server-health', async (_event, serverUrl: string) => {
|
||||
return await readDesktopUpdateServerHealth(serverUrl);
|
||||
});
|
||||
|
||||
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
||||
return await configureDesktopUpdaterContext(context);
|
||||
});
|
||||
@@ -431,7 +331,6 @@ export function setupSystemHandlers(): void {
|
||||
const snapshot = updateDesktopSettings(patch);
|
||||
|
||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||
updateCloseToTraySetting(snapshot.closeToTray);
|
||||
await handleDesktopSettingsChanged();
|
||||
return snapshot;
|
||||
});
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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 AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -51,12 +50,6 @@ export interface DesktopUpdateServerContext {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
availableVersions: string[];
|
||||
@@ -91,23 +84,6 @@ export interface DesktopUpdateState {
|
||||
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 {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
@@ -140,28 +116,17 @@ export interface ElectronAPI {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
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>;
|
||||
getDesktopSettings: () => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||
requestWindowAttention: () => Promise<boolean>;
|
||||
clearWindowAttention: () => Promise<boolean>;
|
||||
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||
restartToApplyUpdate: () => Promise<boolean>;
|
||||
@@ -169,7 +134,6 @@ export interface ElectronAPI {
|
||||
setDesktopSettings: (patch: {
|
||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||
autoStart?: boolean;
|
||||
closeToTray?: boolean;
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
@@ -177,7 +141,6 @@ export interface ElectronAPI {
|
||||
}) => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -241,29 +204,9 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
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'),
|
||||
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'),
|
||||
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
|
||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
||||
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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,11 +18,6 @@ interface ReleaseManifestEntry {
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface ServerHealthResponse {
|
||||
releaseManifestUrl?: string;
|
||||
serverVersion?: string;
|
||||
}
|
||||
|
||||
interface UpdateVersionInfo {
|
||||
version: string;
|
||||
}
|
||||
@@ -58,12 +53,6 @@ export interface DesktopUpdateServerContext {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
@@ -89,8 +78,6 @@ export interface DesktopUpdateState {
|
||||
|
||||
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 currentContext: DesktopUpdateServerContext = {
|
||||
manifestUrls: [],
|
||||
@@ -401,47 +388,6 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
|
||||
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 {
|
||||
if (errors.length === 0) {
|
||||
return 'No valid release manifest could be loaded.';
|
||||
@@ -778,12 +724,6 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
|
||||
return desktopUpdateState;
|
||||
}
|
||||
|
||||
export async function readDesktopUpdateServerHealth(
|
||||
serverUrl: string
|
||||
): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||
return await loadServerHealth(serverUrl);
|
||||
}
|
||||
|
||||
export function restartToApplyUpdate(): boolean {
|
||||
if (!desktopUpdateState.restartRequired) {
|
||||
return false;
|
||||
|
||||
@@ -2,21 +2,13 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
Menu,
|
||||
session,
|
||||
shell,
|
||||
Tray
|
||||
shell
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
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 {
|
||||
const basePath = app.isPackaged
|
||||
@@ -46,124 +38,13 @@ export function getDockIconPath(): string | undefined {
|
||||
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 {
|
||||
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> {
|
||||
const windowIconPath = getWindowIconPath();
|
||||
|
||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||
ensureTray();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
@@ -224,46 +105,10 @@ export async function createWindow(): Promise<void> {
|
||||
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 = 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.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
|
||||
@@ -199,7 +199,7 @@ module.exports = tseslint.config(
|
||||
},
|
||||
// HTML template formatting rules (external Angular templates only)
|
||||
{
|
||||
files: ['toju-app/src/app/**/*.html'],
|
||||
files: ['src/app/**/*.html'],
|
||||
plugins: { 'no-dashes': noDashPlugin },
|
||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||
rules: {
|
||||
|
||||
184
package-lock.json
generated
184
package-lock.json
generated
@@ -14,12 +14,6 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^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/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -33,7 +27,6 @@
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
@@ -2704,109 +2697,6 @@
|
||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||
"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": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -5782,41 +5672,6 @@
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||
@@ -6010,12 +5865,6 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
||||
@@ -14289,21 +14138,6 @@
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -14932,12 +14766,6 @@
|
||||
"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": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
@@ -27954,12 +27782,6 @@
|
||||
"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": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
||||
@@ -30552,12 +30374,6 @@
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"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": {
|
||||
"version": "7.2.0",
|
||||
"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",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
"ng": "cd \"toju-app\" && ng",
|
||||
"ng": "ng",
|
||||
"prebuild": "npm run bundle:rnnoise",
|
||||
"prestart": "npm run bundle:rnnoise",
|
||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
|
||||
"start": "cd \"toju-app\" && ng serve",
|
||||
"build": "cd \"toju-app\" && ng build",
|
||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:electron": "tsc -p tsconfig.electron.json",
|
||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||
"test": "cd \"toju-app\" && ng test",
|
||||
"build:prod": "ng build --configuration production --base-href='./'",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"server:build": "cd server && npm run build",
|
||||
"server:start": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
"electron": "npm run build && npm run build:electron && 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": "ng 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: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\"",
|
||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||
@@ -40,8 +40,8 @@
|
||||
"dev:app": "npm run electron:dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||
"format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
|
||||
"format": "prettier --write \"src/app/**/*.html\"",
|
||||
"format:check": "prettier --check \"src/app/**/*.html\"",
|
||||
"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:manifest": "node tools/generate-release-manifest.js",
|
||||
@@ -60,12 +60,6 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^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/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -79,7 +73,6 @@
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
Binary file not shown.
@@ -1,11 +1,6 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
ServerChannelPermissionEntity,
|
||||
ServerChannelEntity,
|
||||
ServerEntity,
|
||||
ServerRoleEntity,
|
||||
ServerTagEntity,
|
||||
ServerUserRoleEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
@@ -16,16 +11,9 @@ import { DeleteServerCommand } from '../../types';
|
||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const { serverId } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(ServerTagEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerChannelEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerRoleEntity).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);
|
||||
});
|
||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { replaceServerRelations } from '../../relations';
|
||||
import { UpsertServerCommand } from '../../types';
|
||||
|
||||
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const { server } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(ServerEntity);
|
||||
const entity = repo.create({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
@@ -18,18 +15,10 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
slowModeInterval: server.slowModeInterval ?? 0,
|
||||
tags: JSON.stringify(server.tags),
|
||||
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,7 +6,6 @@ import {
|
||||
ServerPayload,
|
||||
JoinRequestPayload
|
||||
} from './types';
|
||||
import { relationRecordToServerPayload } from './relations';
|
||||
|
||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
return {
|
||||
@@ -18,24 +17,7 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? []
|
||||
});
|
||||
|
||||
export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -47,12 +29,7 @@ export function rowToServer(
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
slowModeInterval: relationPayload.slowModeInterval,
|
||||
tags: relationPayload.tags,
|
||||
channels: relationPayload.channels,
|
||||
roles: relationPayload.roles,
|
||||
roleAssignments: relationPayload.roleAssignments,
|
||||
channelPermissions: relationPayload.channelPermissions,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
createdAt: row.createdAt,
|
||||
lastSeen: row.lastSeen
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const rows = await repo.find({ where: { isPrivate: 0 } });
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||
return rows.map(rowToServer);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,10 @@ import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { GetServerByIdQuery } from '../../types';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.serverId } });
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToServer(row, relationsByServerId.get(row.id));
|
||||
return row ? rowToServer(row) : null;
|
||||
}
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
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,53 +28,6 @@ export interface AuthUserPayload {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -86,12 +39,7 @@ export interface ServerPayload {
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
slowModeInterval?: number;
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
roles?: AccessRolePayload[];
|
||||
roleAssignments?: RoleAssignmentPayload[];
|
||||
channelPermissions?: ChannelPermissionPayload[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,6 @@ import { DataSource } from 'typeorm';
|
||||
import {
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
@@ -59,11 +54,6 @@ export async function initDatabase(): Promise<void> {
|
||||
entities: [
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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 })
|
||||
currentUsers!: number;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
slowModeInterval!: number;
|
||||
@Column('text', { default: '[]' })
|
||||
tags!: string;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_tags')
|
||||
export class ServerTagEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('integer')
|
||||
position!: number;
|
||||
|
||||
@Column('text')
|
||||
value!: string;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,10 +1,5 @@
|
||||
export { AuthUserEntity } from './AuthUserEntity';
|
||||
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 { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
|
||||
@@ -25,7 +25,6 @@ export class InitialSchema1000000000000 implements MigrationInterface {
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"channels" TEXT NOT NULL DEFAULT '[]',
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
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,15 +1,7 @@
|
||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
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 = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001,
|
||||
ServerChannels1000000000002,
|
||||
RepairLegacyVoiceChannels1000000000003,
|
||||
NormalizeServerArrays1000000000004,
|
||||
ServerRoleAccessControl1000000000005
|
||||
ServerAccessControl1000000000001
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { getUserById } from '../cqrs';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import { getActiveServerInvite } from '../services/server-access.service';
|
||||
import {
|
||||
@@ -282,7 +283,7 @@ invitesApiRouter.get('/:id', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||
}
|
||||
|
||||
const server = bundle.server;
|
||||
const server = rowToServer(bundle.server);
|
||||
|
||||
res.json({
|
||||
id: bundle.invite.id,
|
||||
@@ -314,7 +315,7 @@ invitePageRouter.get('/:id', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const server = bundle.server;
|
||||
const server = rowToServer(bundle.server);
|
||||
const owner = await getUserById(server.ownerId);
|
||||
|
||||
res.send(renderInvitePage({
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
updateJoinRequestStatus
|
||||
} from '../cqrs';
|
||||
import { notifyUser } from '../websocket/broadcast';
|
||||
import { resolveServerPermission } from '../services/server-permissions.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -20,7 +19,7 @@ router.put('/:id', async (req, res) => {
|
||||
|
||||
const server = await getServerById(request.serverId);
|
||||
|
||||
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
|
||||
if (!server || server.ownerId !== ownerId)
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
|
||||
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Response, Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import {
|
||||
getAllPublicServers,
|
||||
getServerById,
|
||||
@@ -27,53 +27,15 @@ import {
|
||||
buildInviteUrl,
|
||||
getRequestOrigin
|
||||
} from './invite-utils';
|
||||
import {
|
||||
canManageServerUpdate,
|
||||
canModerateServerMember,
|
||||
resolveServerPermission
|
||||
} from '../services/server-permissions.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${name.toLocaleLowerCase()}`;
|
||||
function normalizeRole(role: unknown): string | null {
|
||||
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||
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;
|
||||
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
||||
return !!role && allowedRoles.includes(role);
|
||||
}
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
@@ -162,8 +124,7 @@ router.post('/', async (req, res) => {
|
||||
isPrivate,
|
||||
maxUsers,
|
||||
password,
|
||||
tags,
|
||||
channels
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
if (!name || !ownerId || !ownerPublicKey)
|
||||
@@ -182,7 +143,6 @@ router.post('/', async (req, res) => {
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
tags: tags ?? [],
|
||||
channels: normalizeServerChannels(channels),
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
@@ -201,35 +161,27 @@ router.put('/:id', async (req, res) => {
|
||||
password,
|
||||
hasPassword: _ignoredHasPassword,
|
||||
passwordHash: _ignoredPasswordHash,
|
||||
channels,
|
||||
...updates
|
||||
} = req.body;
|
||||
const existing = await getServerById(id);
|
||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||
const normalizedRole = normalizeRole(actingRole);
|
||||
|
||||
if (!existing)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!authenticatedOwnerId) {
|
||||
return res.status(400).json({ error: 'Missing currentOwnerId' });
|
||||
}
|
||||
|
||||
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
|
||||
...updates,
|
||||
channels,
|
||||
password,
|
||||
actingRole
|
||||
})) {
|
||||
if (
|
||||
existing.ownerId !== authenticatedOwnerId &&
|
||||
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
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 server: ServerPayload = {
|
||||
...existing,
|
||||
...updates,
|
||||
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
|
||||
hasPassword: !!nextPasswordHash,
|
||||
passwordHash: nextPasswordHash,
|
||||
lastSeen: Date.now()
|
||||
@@ -297,7 +249,7 @@ router.post('/:id/invites', async (req, res) => {
|
||||
|
||||
router.post('/:id/moderation/kick', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, targetUserId } = req.body;
|
||||
const { actorUserId, actorRole, targetUserId } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
@@ -308,7 +260,14 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
@@ -319,7 +278,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
||||
|
||||
router.post('/:id/moderation/ban', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
@@ -330,7 +289,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
@@ -349,14 +315,21 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
||||
|
||||
router.post('/:id/moderation/unban', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, banId, targetUserId } = req.body;
|
||||
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
|
||||
if (
|
||||
server.ownerId !== actorUserId &&
|
||||
!isAllowedRole(normalizeRole(actorRole), [
|
||||
'host',
|
||||
'admin',
|
||||
'moderator'
|
||||
])
|
||||
) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ServerMembershipEntity
|
||||
} from '../entities';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { loadServerRelationsMap } from '../cqrs/relations';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
|
||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||
@@ -58,12 +57,6 @@ function getBanRepository() {
|
||||
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 {
|
||||
const normalized = password?.trim() ?? '';
|
||||
|
||||
@@ -201,7 +194,7 @@ export async function createServerInvite(
|
||||
|
||||
export async function getActiveServerInvite(
|
||||
inviteId: string
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerPayload } | null> {
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||
@@ -221,10 +214,7 @@ export async function getActiveServerInvite(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
invite,
|
||||
server: await toServerPayload(server)
|
||||
};
|
||||
return { invite, server };
|
||||
}
|
||||
|
||||
export async function joinServerWithAccess(options: {
|
||||
@@ -252,7 +242,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: await toServerPayload(server),
|
||||
server: rowToServer(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
@@ -270,7 +260,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: await toServerPayload(server),
|
||||
server: rowToServer(server),
|
||||
via: 'invite'
|
||||
};
|
||||
}
|
||||
@@ -282,7 +272,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: true,
|
||||
server: await toServerPayload(server),
|
||||
server: rowToServer(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
@@ -298,7 +288,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: await toServerPayload(server),
|
||||
server: rowToServer(server),
|
||||
via: 'password'
|
||||
};
|
||||
}
|
||||
@@ -311,7 +301,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: await toServerPayload(server),
|
||||
server: rowToServer(server),
|
||||
via: 'public'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -134,15 +134,11 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
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)) {
|
||||
broadcastToServer(typingSid, {
|
||||
type: 'user_typing',
|
||||
serverId: typingSid,
|
||||
channelId,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
}, user.oderId);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { routes } from './app.routes';
|
||||
import { messagesReducer } from './store/messages/messages.reducer';
|
||||
import { usersReducer } from './store/users/users.reducer';
|
||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||
import { NotificationsEffects } from './domains/notifications';
|
||||
import { MessagesEffects } from './store/messages/messages.effects';
|
||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||
import { UsersEffects } from './store/users/users.effects';
|
||||
@@ -33,7 +32,6 @@ export const appConfig: ApplicationConfig = {
|
||||
rooms: roomsReducer
|
||||
}),
|
||||
provideEffects([
|
||||
NotificationsEffects,
|
||||
MessagesEffects,
|
||||
MessagesSyncEffects,
|
||||
UsersEffects,
|
||||
60
src/app/app.html
Normal file
60
src/app/app.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<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" />
|
||||
217
src/app/app.ts
Normal file
217
src/app/app.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/* 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 './infrastructure/persistence';
|
||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
import { VoiceSessionFacade } from './domains/voice-session';
|
||||
import { ExternalLinkService } from './core/platform';
|
||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/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';
|
||||
|
||||
@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(ServerDirectoryFacade);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
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.electronBridge.getApi();
|
||||
|
||||
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 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,12 +1,8 @@
|
||||
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
||||
export const STORAGE_KEY_GENERAL_SETTINGS = 'metoyou_general_settings';
|
||||
export const STORAGE_KEY_LAST_VIEWED_CHAT = 'metoyou_lastViewedChat';
|
||||
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
||||
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
||||
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
|
||||
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
|
||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||
@@ -10,6 +10,7 @@
|
||||
* instead of this file.
|
||||
*/
|
||||
|
||||
// ── shared-kernel re-exports ────────────────────────────────────────
|
||||
export type {
|
||||
User,
|
||||
UserStatus,
|
||||
@@ -49,4 +50,5 @@ export type {
|
||||
ChatAttachmentMeta
|
||||
} from '../../shared-kernel';
|
||||
|
||||
// ── domain re-exports ───────────────────────────────────────────────
|
||||
export type { ServerInfo } from '../../domains/server-directory';
|
||||
@@ -57,12 +57,6 @@ export interface DesktopUpdateServerContext {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
@@ -89,7 +83,6 @@ export interface DesktopUpdateState {
|
||||
export interface DesktopSettingsSnapshot {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -100,30 +93,12 @@ export interface DesktopSettingsSnapshot {
|
||||
export interface DesktopSettingsPatch {
|
||||
autoUpdateMode?: AutoUpdateMode;
|
||||
autoStart?: boolean;
|
||||
closeToTray?: boolean;
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
vaapiVideoEncode?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ElectronCommand {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
@@ -149,19 +124,9 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
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>;
|
||||
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||
requestWindowAttention: () => Promise<boolean>;
|
||||
clearWindowAttention: () => Promise<boolean>;
|
||||
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||
restartToApplyUpdate: () => Promise<boolean>;
|
||||
@@ -433,7 +433,7 @@ class DebugNetworkSnapshotBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'screen-state' || type === 'camera-state') {
|
||||
if (type === 'screen-state') {
|
||||
const subjectNode = direction === 'outbound'
|
||||
? this.ensureLocalNetworkNode(
|
||||
state,
|
||||
@@ -442,14 +442,12 @@ class DebugNetworkSnapshotBuilder {
|
||||
this.getPayloadString(payload, 'displayName')
|
||||
)
|
||||
: peerNode;
|
||||
const isStreaming = type === 'screen-state'
|
||||
? this.getPayloadBoolean(payload, 'isScreenSharing')
|
||||
: this.getPayloadBoolean(payload, 'isCameraEnabled');
|
||||
const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing');
|
||||
|
||||
if (isStreaming !== null) {
|
||||
subjectNode.isStreaming = isStreaming;
|
||||
if (isScreenSharing !== null) {
|
||||
subjectNode.isStreaming = isScreenSharing;
|
||||
|
||||
if (!isStreaming)
|
||||
if (!isScreenSharing)
|
||||
subjectNode.streams.video = 0;
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,17 @@ import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server
|
||||
import {
|
||||
type AutoUpdateMode,
|
||||
type DesktopUpdateServerContext,
|
||||
type DesktopUpdateServerHealthSnapshot,
|
||||
type DesktopUpdateServerVersionStatus,
|
||||
type DesktopUpdateState,
|
||||
type ElectronApi
|
||||
} from '../platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
|
||||
interface ServerHealthResponse {
|
||||
releaseManifestUrl?: string;
|
||||
serverVersion?: string;
|
||||
}
|
||||
|
||||
interface ServerHealthSnapshot {
|
||||
endpointId: string;
|
||||
manifestUrl: string | null;
|
||||
@@ -25,6 +29,7 @@ interface ServerHealthSnapshot {
|
||||
}
|
||||
|
||||
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
|
||||
|
||||
function createInitialState(): DesktopUpdateState {
|
||||
return {
|
||||
@@ -287,9 +292,14 @@ export class DesktopAppUpdateService {
|
||||
|
||||
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
|
||||
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
|
||||
const api = this.getElectronApi();
|
||||
|
||||
if (!api?.getAutoUpdateServerHealth) {
|
||||
try {
|
||||
const response = await fetch(`${sanitizedServerUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
endpointId: endpoint.id,
|
||||
manifestUrl: null,
|
||||
@@ -298,12 +308,14 @@ export class DesktopAppUpdateService {
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await api.getAutoUpdateServerHealth(sanitizedServerUrl);
|
||||
const payload = await response.json() as ServerHealthResponse;
|
||||
const serverVersion = normalizeOptionalString(payload.serverVersion);
|
||||
|
||||
return {
|
||||
endpointId: endpoint.id,
|
||||
...this.normalizeHealthSnapshot(payload)
|
||||
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl),
|
||||
serverVersion,
|
||||
serverVersionStatus: serverVersion ? 'reported' : 'missing'
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -315,22 +327,6 @@ export class DesktopAppUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeHealthSnapshot(
|
||||
snapshot: DesktopUpdateServerHealthSnapshot
|
||||
): Omit<ServerHealthSnapshot, 'endpointId'> {
|
||||
const serverVersion = normalizeOptionalString(snapshot.serverVersion);
|
||||
|
||||
return {
|
||||
manifestUrl: normalizeOptionalHttpUrl(snapshot.manifestUrl),
|
||||
serverVersion,
|
||||
serverVersionStatus: serverVersion
|
||||
? snapshot.serverVersionStatus
|
||||
: snapshot.serverVersionStatus === 'reported'
|
||||
? 'missing'
|
||||
: snapshot.serverVersionStatus
|
||||
};
|
||||
}
|
||||
|
||||
private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
|
||||
const api = this.getElectronApi();
|
||||
|
||||
@@ -13,7 +13,7 @@ export enum AppSound {
|
||||
}
|
||||
|
||||
/** Path prefix for audio assets (served from the `assets/audio/` folder). */
|
||||
const AUDIO_BASE = 'assets/audio';
|
||||
const AUDIO_BASE = '/assets/audio';
|
||||
/** File extension used for all sound-effect assets. */
|
||||
const AUDIO_EXT = 'wav';
|
||||
/** localStorage key for persisting notification volume. */
|
||||
@@ -36,8 +36,6 @@ export class NotificationAudioService {
|
||||
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
||||
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
||||
|
||||
private readonly sources = new Map<AppSound, string>();
|
||||
|
||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||
readonly notificationVolume = signal(this.loadVolume());
|
||||
|
||||
@@ -48,22 +46,13 @@ export class NotificationAudioService {
|
||||
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
|
||||
private preload(): void {
|
||||
for (const sound of Object.values(AppSound)) {
|
||||
const src = this.resolveAudioUrl(sound);
|
||||
const audio = new Audio();
|
||||
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`);
|
||||
|
||||
audio.preload = 'auto';
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
|
||||
this.sources.set(sound, src);
|
||||
this.cache.set(sound, audio);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAudioUrl(sound: AppSound): string {
|
||||
return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString();
|
||||
}
|
||||
|
||||
/** Read persisted volume from localStorage, falling back to the default. */
|
||||
private loadVolume(): number {
|
||||
try {
|
||||
@@ -107,9 +96,8 @@ export class NotificationAudioService {
|
||||
*/
|
||||
play(sound: AppSound, volumeOverride?: number): void {
|
||||
const cached = this.cache.get(sound);
|
||||
const src = this.sources.get(sound);
|
||||
|
||||
if (!cached || !src)
|
||||
if (!cached)
|
||||
return;
|
||||
|
||||
const vol = volumeOverride ?? this.notificationVolume();
|
||||
@@ -117,23 +105,12 @@ export class NotificationAudioService {
|
||||
if (vol === 0)
|
||||
return; // skip playback when muted
|
||||
|
||||
if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) {
|
||||
cached.load();
|
||||
}
|
||||
|
||||
// Clone so overlapping plays don't cut each other off.
|
||||
const clone = cached.cloneNode(true) as HTMLAudioElement;
|
||||
|
||||
clone.preload = 'auto';
|
||||
clone.volume = Math.max(0, Math.min(1, vol));
|
||||
clone.play().catch(() => {
|
||||
const fallback = new Audio(src);
|
||||
|
||||
fallback.preload = 'auto';
|
||||
fallback.volume = clone.volume;
|
||||
fallback.play().catch(() => {
|
||||
/* swallow autoplay errors */
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user