Compare commits

...

15 Commits

Author SHA1 Message Date
Myx
8b6578da3c fix: Notification audio
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 11m55s
Queue Release Build / build-linux (push) Successful in 30m56s
Queue Release Build / build-windows (push) Successful in 27m50s
Queue Release Build / finalize (push) Successful in 2m0s
2026-03-30 21:14:26 +02:00
Myx
851d6ae759 fix: Prefer cached channels before loaded 2026-03-30 20:37:24 +02:00
1e833ec7f2 Merge pull request 'Restructure' (#9) from maybe-ddd into main
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / build-linux (push) Successful in 37m23s
Queue Release Build / build-windows (push) Successful in 28m39s
Queue Release Build / finalize (push) Successful in 2m7s
Reviewed-on: #9
2026-03-30 02:56:34 +00:00
Myx
64e34ad586 feat: basic selected server indicator 2026-03-30 04:54:02 +02:00
Myx
e3b23247a9 feat: Close to tray 2026-03-30 04:48:34 +02:00
Myx
42ac712571 feat: Add notifications 2026-03-30 04:41:58 +02:00
Myx
b7d4bf20e3 feat: Add webcam basic support 2026-03-30 03:10:44 +02:00
Myx
727059fb52 Add seperation of voice channels, creation of new ones, and move around users 2026-03-30 02:11:39 +02:00
Myx
83694570e3 feat: Allow admin to create new text channels 2026-03-30 01:25:56 +02:00
Myx
109402cdd6 perf: Health snapshot changes 2026-03-30 00:28:45 +02:00
Myx
eb23fd71ec perf: Optimizing the image loading
Does no longer load all klipy images through image proxy from signal server. Improves loading performance.
2026-03-30 00:26:28 +02:00
Myx
11917f3412 fix: Make attachments unique when downloaded
Fixes the issue with attachments replacing each other locally so files with same filename appears as the same file
2026-03-30 00:08:53 +02:00
Myx
8162e0444a Move toju-app into own its folder 2026-03-29 23:55:24 +02:00
Myx
0467a7b612 documentation improvement 2026-03-23 01:34:18 +01:00
Myx
971a5afb8b ddd test 2 2026-03-23 00:42:08 +01:00
330 changed files with 7190 additions and 1246 deletions

View File

@@ -67,8 +67,10 @@ jobs:
- name: Build application - name: Build application
run: | run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
cd toju-app
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
cd ..
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
cd server cd server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js
@@ -120,8 +122,10 @@ jobs:
- name: Build application - name: Build application
run: | run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
Push-Location "toju-app"
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
Pop-Location
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
Push-Location server Push-Location server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js

7
.gitignore vendored
View File

@@ -6,7 +6,9 @@
/tmp /tmp
/out-tsc /out-tsc
/bazel-out /bazel-out
*.sqlite
*/architecture.md
/docs
# Node # Node
/node_modules /node_modules
npm-debug.log npm-debug.log
@@ -51,3 +53,6 @@ Thumbs.db
.certs/ .certs/
/server/data/variables.json /server/data/variables.json
dist-server/* dist-server/*
AGENTS.md
doc/**

View File

@@ -1,16 +1,14 @@
<img src="./images/icon.png" width="100" height="100">
# Toju / Zoracord # Toju / Zoracord
Desktop chat app with three parts: Desktop chat app with four parts:
- `src/` Angular client - `src/` Angular client
- `electron/` desktop shell, IPC, and local database - `electron/` desktop shell, IPC, and local database
- `server/` directory server, join request API, and websocket events - `server/` directory server, join request API, and websocket events
- `website/` Toju website served at toju.app
## 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 ## Install
@@ -58,3 +56,64 @@ Inside `server/`:
- `npm run dev` starts the server with reload - `npm run dev` starts the server with reload
- `npm run build` compiles to `dist/` - `npm run build` compiles to `dist/`
- `npm run start` runs the compiled server - `npm run start` runs the compiled server
# Images
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
## Main Toju app Structure
| Path | Description |
|------|-------------|
| `src/app/` | Main application root |
| `src/app/core/` | Core utilities, services, models |
| `src/app/domains/` | Domain-driven modules |
| `src/app/features/` | UI feature modules |
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
| `src/app/shared/` | Shared UI components |
| `src/app/shared-kernel/` | Shared domain contracts & models |
| `src/app/store/` | Global state management |
| `src/assets/` | Static assets |
| `src/environments/` | Environment configs |
---
### Domains
| Path | Link |
|------|------|
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
---
### Infrastructure
| Path | Link |
|------|------|
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
---
### Shared Kernel
| Path | Link |
|------|------|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
---
### Entry Points
| File | Link |
|------|------|
| Main | [main.ts](src/main.ts) |
| Index HTML | [index.html](src/index.html) |
| App Root | [app/app.ts](src/app/app.ts) |

4
dev.sh
View File

@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
"$DIR/generate-cert.sh" "$DIR/generate-cert.sh"
fi fi
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key" NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
WAIT_URL="https://localhost:4200" WAIT_URL="https://localhost:4200"
HEALTH_URL="https://localhost:3001/api/health" HEALTH_URL="https://localhost:3001/api/health"
export NODE_TLS_REJECT_UNAUTHORIZED=0 export NODE_TLS_REJECT_UNAUTHORIZED=0
else else
NG_SERVE="ng serve --host=0.0.0.0" NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
WAIT_URL="http://localhost:4200" WAIT_URL="http://localhost:4200"
HEALTH_URL="http://localhost:3001/api/health" HEALTH_URL="http://localhost:3001/api/health"
fi fi

View File

@@ -1,139 +0,0 @@
# 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
core/
constants.ts # cross-domain technical constants only
helpers/ # transitional pure helpers still being moved out
models/ # reusable cross-domain contracts only
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. `core/models/` should only hold shared cross-domain contracts. Domain-specific models belong inside their domain folder.
## 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 cross-domain contracts should stay in `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/`.
- 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`.
## 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.

View File

@@ -7,7 +7,13 @@ import {
destroyDatabase, destroyDatabase,
getDataSource getDataSource
} from '../db/database'; } from '../db/database';
import { createWindow, getDockIconPath } from '../window/create-window'; import {
createWindow,
getDockIconPath,
getMainWindow,
prepareWindowForAppQuit,
showMainWindow
} from '../window/create-window';
import { import {
setupCqrsHandlers, setupCqrsHandlers,
setupSystemHandlers, setupSystemHandlers,
@@ -30,8 +36,13 @@ export function registerAppLifecycle(): void {
await createWindow(); await createWindow();
app.on('activate', () => { app.on('activate', () => {
if (getMainWindow()) {
void showMainWindow();
return;
}
if (BrowserWindow.getAllWindows().length === 0) if (BrowserWindow.getAllWindows().length === 0)
createWindow(); void createWindow();
}); });
}); });
@@ -41,6 +52,8 @@ export function registerAppLifecycle(): void {
}); });
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();
shutdownDesktopUpdater(); shutdownDesktopUpdater();

View File

@@ -0,0 +1,18 @@
import { DataSource, MoreThan } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
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' }
});
return rows.map(rowToMessage);
}

View File

@@ -4,6 +4,7 @@ import {
QueryTypeKey, QueryTypeKey,
Query, Query,
GetMessagesQuery, GetMessagesQuery,
GetMessagesSinceQuery,
GetMessageByIdQuery, GetMessageByIdQuery,
GetReactionsForMessageQuery, GetReactionsForMessageQuery,
GetUserQuery, GetUserQuery,
@@ -13,6 +14,7 @@ import {
GetAttachmentsForMessageQuery GetAttachmentsForMessageQuery
} from '../types'; } from '../types';
import { handleGetMessages } from './handlers/getMessages'; import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
import { handleGetMessageById } from './handlers/getMessageById'; import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage'; import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser'; import { handleGetUser } from './handlers/getUser';
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource), [QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource), [QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource), [QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource), [QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),

View File

@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
export const QueryType = { export const QueryType = {
GetMessages: 'get-messages', GetMessages: 'get-messages',
GetMessagesSince: 'get-messages-since',
GetMessageById: 'get-message-by-id', GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message', GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user', GetUser: 'get-user',
@@ -160,6 +161,7 @@ export type Command =
| ClearAllDataCommand; | ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } } export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } } export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
@@ -174,6 +176,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
export type Query = export type Query =
| GetMessagesQuery | GetMessagesQuery
| GetMessagesSinceQuery
| GetMessageByIdQuery | GetMessageByIdQuery
| GetReactionsForMessageQuery | GetReactionsForMessageQuery
| GetUserQuery | GetUserQuery

View File

@@ -7,6 +7,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings { export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -21,6 +22,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto', autoUpdateMode: 'auto',
autoStart: true, autoStart: true,
closeToTray: true,
hardwareAcceleration: true, hardwareAcceleration: true,
manifestUrls: [], manifestUrls: [],
preferredVersion: null, preferredVersion: null,
@@ -86,6 +88,9 @@ export function readDesktopSettings(): DesktopSettings {
autoStart: typeof parsed.autoStart === 'boolean' autoStart: typeof parsed.autoStart === 'boolean'
? parsed.autoStart ? parsed.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart, : DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof parsed.closeToTray === 'boolean'
? parsed.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean' vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode ? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode, : DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
@@ -110,6 +115,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
autoStart: typeof mergedSettings.autoStart === 'boolean' autoStart: typeof mergedSettings.autoStart === 'boolean'
? mergedSettings.autoStart ? mergedSettings.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart, : DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
? mergedSettings.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration ? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,

View File

@@ -4,6 +4,7 @@ import {
desktopCapturer, desktopCapturer,
dialog, dialog,
ipcMain, ipcMain,
Notification,
shell shell
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -28,10 +29,16 @@ import {
getDesktopUpdateState, getDesktopUpdateState,
handleDesktopSettingsChanged, handleDesktopSettingsChanged,
restartToApplyUpdate, restartToApplyUpdate,
readDesktopUpdateServerHealth,
type DesktopUpdateServerContext type DesktopUpdateServerContext
} from '../update/desktop-updater'; } from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links'; import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start'; import { synchronizeAutoStartSetting } from '../app/auto-start';
import {
getMainWindow,
getWindowIconPath,
updateCloseToTraySetting
} from '../window/create-window';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [ const FILE_CLIPBOARD_FORMATS = [
@@ -85,6 +92,12 @@ interface ClipboardFilePayload {
path?: string; path?: string;
} }
interface DesktopNotificationPayload {
body: string;
requestAttention?: boolean;
title: string;
}
function resolveLinuxDisplayServer(): string { function resolveLinuxDisplayServer(): string {
if (process.platform !== 'linux') { if (process.platform !== 'linux') {
return 'N/A'; return 'N/A';
@@ -315,8 +328,78 @@ export function setupSystemHandlers(): void {
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot()); ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
const body = typeof payload?.body === 'string' ? payload.body : '';
const mainWindow = getMainWindow();
const suppressSystemNotification = mainWindow?.isVisible() === true
&& !mainWindow.isMinimized()
&& mainWindow.isMaximized();
if (!title) {
return false;
}
if (!suppressSystemNotification && Notification.isSupported()) {
try {
const notification = new Notification({
title,
body,
icon: getWindowIconPath(),
silent: true
});
notification.on('click', () => {
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
notification.show();
} catch {
// Ignore notification center failures and still attempt taskbar attention.
}
}
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
mainWindow.flashFrame(true);
}
return true;
});
ipcMain.handle('request-window-attention', () => {
const mainWindow = getMainWindow();
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
return false;
}
mainWindow.flashFrame(true);
return true;
});
ipcMain.handle('clear-window-attention', () => {
getMainWindow()?.flashFrame(false);
return true;
});
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState()); ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
return await readDesktopUpdateServerHealth(serverUrl);
});
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => { ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
return await configureDesktopUpdaterContext(context); return await configureDesktopUpdaterContext(context);
}); });
@@ -331,6 +414,7 @@ export function setupSystemHandlers(): void {
const snapshot = updateDesktopSettings(patch); const snapshot = updateDesktopSettings(patch);
await synchronizeAutoStartSetting(snapshot.autoStart); await synchronizeAutoStartSetting(snapshot.autoStart);
updateCloseToTraySetting(snapshot.closeToTray);
await handleDesktopSettingsChanged(); await handleDesktopSettingsChanged();
return snapshot; return snapshot;
}); });

View File

@@ -5,6 +5,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monit
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended'; const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received'; const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -50,6 +51,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState { export interface DesktopUpdateState {
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
availableVersions: string[]; availableVersions: string[];
@@ -84,6 +91,17 @@ export interface DesktopUpdateState {
targetVersion: string | null; targetVersion: string | null;
} }
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
function readLinuxDisplayServer(): string { function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') { if (process.platform !== 'linux') {
return 'N/A'; return 'N/A';
@@ -120,13 +138,19 @@ export interface ElectronAPI {
getDesktopSettings: () => Promise<{ getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
runtimeHardwareAcceleration: boolean; runtimeHardwareAcceleration: boolean;
restartRequired: boolean; restartRequired: boolean;
}>; }>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>; getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>; configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>; checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>; restartToApplyUpdate: () => Promise<boolean>;
@@ -134,6 +158,7 @@ export interface ElectronAPI {
setDesktopSettings: (patch: { setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version'; autoUpdateMode?: 'auto' | 'off' | 'version';
autoStart?: boolean; autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean; hardwareAcceleration?: boolean;
manifestUrls?: string[]; manifestUrls?: string[];
preferredVersion?: string | null; preferredVersion?: string | null;
@@ -141,6 +166,7 @@ export interface ElectronAPI {
}) => Promise<{ }) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -206,7 +232,22 @@ const electronAPI: ElectronAPI = {
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'), consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'), getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
onWindowStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
listener(state);
};
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'), getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context), configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'), checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'), restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),

View File

@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
version: string; version: string;
} }
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface UpdateVersionInfo { interface UpdateVersionInfo {
version: string; version: string;
} }
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState { export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
availableVersions: string[]; availableVersions: string[];
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
let currentCheckPromise: Promise<void> | null = null; let currentCheckPromise: Promise<void> | null = null;
let currentContext: DesktopUpdateServerContext = { let currentContext: DesktopUpdateServerContext = {
manifestUrls: [], manifestUrls: [],
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
return parseReleaseManifest(payload); return parseReleaseManifest(payload);
} }
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
return {
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
if (!sanitizedServerUrl) {
return createUnavailableServerHealthSnapshot();
}
try {
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
headers: {
accept: 'application/json'
},
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
});
if (!response.ok) {
return createUnavailableServerHealthSnapshot();
}
const payload = await response.json() as ServerHealthResponse;
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
return {
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
};
} catch {
return createUnavailableServerHealthSnapshot();
}
}
function formatManifestLoadErrors(errors: string[]): string { function formatManifestLoadErrors(errors: string[]): string {
if (errors.length === 0) { if (errors.length === 0) {
return 'No valid release manifest could be loaded.'; return 'No valid release manifest could be loaded.';
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
return desktopUpdateState; return desktopUpdateState;
} }
export async function readDesktopUpdateServerHealth(
serverUrl: string
): Promise<DesktopUpdateServerHealthSnapshot> {
return await loadServerHealth(serverUrl);
}
export function restartToApplyUpdate(): boolean { export function restartToApplyUpdate(): boolean {
if (!desktopUpdateState.restartRequired) { if (!desktopUpdateState.restartRequired) {
return false; return false;

View File

@@ -2,13 +2,21 @@ import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer, desktopCapturer,
Menu,
session, session,
shell shell,
Tray
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
function getAssetPath(...segments: string[]): string { function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged const basePath = app.isPackaged
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png'); return getExistingAssetPath('macos', '1024x1024.png');
} }
function getTrayIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
return getExistingAssetPath('icon.png');
}
export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null { export function getMainWindow(): BrowserWindow | null {
return mainWindow; return mainWindow;
} }
function destroyTray(): void {
if (!tray) {
return;
}
tray.destroy();
tray = null;
}
function requestAppQuit(): void {
prepareWindowForAppQuit();
app.quit();
}
function ensureTray(): void {
if (tray) {
return;
}
const trayIconPath = getTrayIconPath();
if (!trayIconPath) {
return;
}
tray = new Tray(trayIconPath);
tray.setToolTip('MetoYou');
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: 'Open MetoYou',
click: () => {
void showMainWindow();
}
},
{
type: 'separator'
},
{
label: 'Close MetoYou',
click: () => {
requestAppQuit();
}
}
])
);
tray.on('click', () => {
void showMainWindow();
});
}
function hideWindowToTray(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.hide();
emitWindowState();
}
export function updateCloseToTraySetting(enabled: boolean): void {
closeToTrayEnabled = enabled;
}
export function prepareWindowForAppQuit(): void {
appQuitting = true;
destroyTray();
}
export async function showMainWindow(): Promise<void> {
if (!mainWindow || mainWindow.isDestroyed()) {
await createWindow();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
emitWindowState();
}
function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
isFocused: mainWindow.isFocused(),
isMinimized: mainWindow.isMinimized()
});
}
export async function createWindow(): Promise<void> { export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath(); const windowIconPath = getWindowIconPath();
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: 1400,
height: 900, height: 900,
@@ -105,10 +224,46 @@ export async function createWindow(): Promise<void> {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html')); await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
} }
mainWindow.on('close', (event) => {
if (appQuitting || !closeToTrayEnabled) {
return;
}
event.preventDefault();
hideWindowToTray();
});
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null; mainWindow = null;
}); });
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false);
emitWindowState();
});
mainWindow.on('blur', () => {
emitWindowState();
});
mainWindow.on('minimize', () => {
emitWindowState();
});
mainWindow.on('restore', () => {
emitWindowState();
});
mainWindow.on('show', () => {
emitWindowState();
});
mainWindow.on('hide', () => {
emitWindowState();
});
emitWindowState();
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url); shell.openExternal(url);
return { action: 'deny' }; return { action: 'deny' };

View File

@@ -199,7 +199,7 @@ module.exports = tseslint.config(
}, },
// HTML template formatting rules (external Angular templates only) // HTML template formatting rules (external Angular templates only)
{ {
files: ['src/app/**/*.html'], files: ['toju-app/src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin }, plugins: { 'no-dashes': noDashPlugin },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: { rules: {

View File

@@ -7,22 +7,22 @@
"homepage": "https://git.azaaxin.com/myxelium/Toju", "homepage": "https://git.azaaxin.com/myxelium/Toju",
"main": "dist/electron/main.js", "main": "dist/electron/main.js",
"scripts": { "scripts": {
"ng": "ng", "ng": "cd \"toju-app\" && ng",
"prebuild": "npm run bundle:rnnoise", "prebuild": "npm run bundle:rnnoise",
"prestart": "npm run bundle:rnnoise", "prestart": "npm run bundle:rnnoise",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js", "bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
"start": "ng serve", "start": "cd \"toju-app\" && ng serve",
"build": "ng build", "build": "cd \"toju-app\" && ng build",
"build:electron": "tsc -p tsconfig.electron.json", "build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build", "build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "ng build --configuration production --base-href='./'", "build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
"watch": "ng build --watch --configuration development", "watch": "cd \"toju-app\" && ng build --watch --configuration development",
"test": "ng test", "test": "cd \"toju-app\" && ng test",
"server:build": "cd server && npm run build", "server:build": "cd server && npm run build",
"server:start": "cd server && npm start", "server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev", "server:dev": "cd server && npm run dev",
"electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage", "electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", "electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh", "electron:full": "./dev.sh",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", "electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js", "migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
@@ -40,8 +40,8 @@
"dev:app": "npm run electron:dev", "dev:app": "npm run electron:dev",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix", "lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"format": "prettier --write \"src/app/**/*.html\"", "format": "prettier --write \"toju-app/src/app/**/*.html\"",
"format:check": "prettier --check \"src/app/**/*.html\"", "format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux", "release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win", "release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
"release:manifest": "node tools/generate-release-manifest.js", "release:manifest": "node tools/generate-release-manifest.js",

View File

@@ -16,6 +16,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
maxUsers: server.maxUsers, maxUsers: server.maxUsers,
currentUsers: server.currentUsers, currentUsers: server.currentUsers,
tags: JSON.stringify(server.tags), tags: JSON.stringify(server.tags),
channels: JSON.stringify(server.channels ?? []),
createdAt: server.createdAt, createdAt: server.createdAt,
lastSeen: server.lastSeen lastSeen: server.lastSeen
}); });

View File

@@ -3,10 +3,67 @@ import { ServerEntity } from '../entities/ServerEntity';
import { JoinRequestEntity } from '../entities/JoinRequestEntity'; import { JoinRequestEntity } from '../entities/JoinRequestEntity';
import { import {
AuthUserPayload, AuthUserPayload,
ServerChannelPayload,
ServerPayload, ServerPayload,
JoinRequestPayload JoinRequestPayload
} from './types'; } from './types';
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function parseStringArray(raw: string | null | undefined): string[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === 'string')
: [];
} catch {
return [];
}
}
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
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 ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies ServerChannelPayload;
})
.filter((channel): channel is ServerChannelPayload => !!channel);
} catch {
return [];
}
}
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return { return {
id: row.id, id: row.id,
@@ -29,7 +86,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
isPrivate: !!row.isPrivate, isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers, maxUsers: row.maxUsers,
currentUsers: row.currentUsers, currentUsers: row.currentUsers,
tags: JSON.parse(row.tags || '[]'), tags: parseStringArray(row.tags),
channels: parseServerChannels(row.channels),
createdAt: row.createdAt, createdAt: row.createdAt,
lastSeen: row.lastSeen lastSeen: row.lastSeen
}; };

View File

@@ -28,6 +28,15 @@ export interface AuthUserPayload {
createdAt: number; createdAt: number;
} }
export type ServerChannelType = 'text' | 'voice';
export interface ServerChannelPayload {
id: string;
name: string;
type: ServerChannelType;
position: number;
}
export interface ServerPayload { export interface ServerPayload {
id: string; id: string;
name: string; name: string;
@@ -40,6 +49,7 @@ export interface ServerPayload {
maxUsers: number; maxUsers: number;
currentUsers: number; currentUsers: number;
tags: string[]; tags: string[];
channels: ServerChannelPayload[];
createdAt: number; createdAt: number;
lastSeen: number; lastSeen: number;
} }

View File

@@ -36,6 +36,9 @@ export class ServerEntity {
@Column('text', { default: '[]' }) @Column('text', { default: '[]' })
tags!: string; tags!: string;
@Column('text', { default: '[]' })
channels!: string;
@Column('integer') @Column('integer')
createdAt!: number; createdAt!: number;

View File

@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
"maxUsers" INTEGER NOT NULL DEFAULT 0, "maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0, "currentUsers" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT NOT NULL DEFAULT '[]', "tags" TEXT NOT NULL DEFAULT '[]',
"channels" TEXT NOT NULL DEFAULT '[]',
"createdAt" INTEGER NOT NULL, "createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL "lastSeen" INTEGER NOT NULL
) )

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerChannels1000000000002 implements MigrationInterface {
name = 'ServerChannels1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
}
}

View File

@@ -0,0 +1,119 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
interface LegacyServerRow {
id: string;
channels: string | null;
}
interface LegacyServerChannel {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies LegacyServerChannel;
})
.filter((channel): channel is LegacyServerChannel => !!channel);
} catch {
return [];
}
}
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
const hasTextGeneral = channels.some(
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
);
const hasVoiceAfk = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
);
const hasVoiceGeneral = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
);
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
}
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
return channels;
}
const textChannels = channels.filter((channel) => channel.type === 'text');
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
const repairedVoiceChannels = [
{
id: 'vc-general',
name: 'General',
type: 'voice' as const,
position: 0
},
...voiceChannels
].map((channel, index) => ({
...channel,
position: index
}));
return [
...textChannels,
...repairedVoiceChannels
];
}
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
name = 'RepairLegacyVoiceChannels1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
const channels = normalizeLegacyChannels(row.channels);
const repaired = repairLegacyVoiceChannels(channels);
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
continue;
}
await queryRunner.query(
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
[JSON.stringify(repaired), row.id]
);
}
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Forward-only data repair migration.
}
}

View File

@@ -1,7 +1,11 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema'; import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl'; import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
export const serverMigrations = [ export const serverMigrations = [
InitialSchema1000000000000, InitialSchema1000000000000,
ServerAccessControl1000000000001 ServerAccessControl1000000000001,
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003
]; ];

View File

@@ -1,6 +1,9 @@
import { Response, Router } from 'express'; import { Response, Router } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ServerPayload } from '../cqrs/types'; import {
ServerChannelPayload,
ServerPayload
} from '../cqrs/types';
import { import {
getAllPublicServers, getAllPublicServers,
getServerById, getServerById,
@@ -34,10 +37,51 @@ function normalizeRole(role: unknown): string | null {
return typeof role === 'string' ? role.trim().toLowerCase() : null; return typeof role === 'string' ? role.trim().toLowerCase() : null;
} }
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean { function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
return !!role && allowedRoles.includes(role); return !!role && allowedRoles.includes(role);
} }
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;
}
async function enrichServer(server: ServerPayload, sourceUrl?: string) { async function enrichServer(server: ServerPayload, sourceUrl?: string) {
const owner = await getUserById(server.ownerId); const owner = await getUserById(server.ownerId);
const { passwordHash, ...publicServer } = server; const { passwordHash, ...publicServer } = server;
@@ -124,7 +168,8 @@ router.post('/', async (req, res) => {
isPrivate, isPrivate,
maxUsers, maxUsers,
password, password,
tags tags,
channels
} = req.body; } = req.body;
if (!name || !ownerId || !ownerPublicKey) if (!name || !ownerId || !ownerPublicKey)
@@ -143,6 +188,7 @@ router.post('/', async (req, res) => {
maxUsers: maxUsers ?? 0, maxUsers: maxUsers ?? 0,
currentUsers: 0, currentUsers: 0,
tags: tags ?? [], tags: tags ?? [],
channels: normalizeServerChannels(channels),
createdAt: Date.now(), createdAt: Date.now(),
lastSeen: Date.now() lastSeen: Date.now()
}; };
@@ -161,6 +207,7 @@ router.put('/:id', async (req, res) => {
password, password,
hasPassword: _ignoredHasPassword, hasPassword: _ignoredHasPassword,
passwordHash: _ignoredPasswordHash, passwordHash: _ignoredPasswordHash,
channels,
...updates ...updates
} = req.body; } = req.body;
const existing = await getServerById(id); const existing = await getServerById(id);
@@ -178,10 +225,12 @@ router.put('/:id', async (req, res) => {
} }
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password'); const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null); const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
const server: ServerPayload = { const server: ServerPayload = {
...existing, ...existing,
...updates, ...updates,
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
hasPassword: !!nextPasswordHash, hasPassword: !!nextPasswordHash,
passwordHash: nextPasswordHash, passwordHash: nextPasswordHash,
lastSeen: Date.now() lastSeen: Date.now()

View File

@@ -134,11 +134,15 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void { function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
? message['channelId'].trim()
: 'general';
if (typingSid && user.serverIds.has(typingSid)) { if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, { broadcastToServer(typingSid, {
type: 'user_typing', type: 'user_typing',
serverId: typingSid, serverId: typingSid,
channelId,
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName displayName: user.displayName
}, user.oderId); }, user.oderId);

View File

@@ -1,320 +0,0 @@
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
export type ChannelType = 'text' | 'voice';
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export interface User {
id: string;
oderId: string;
username: string;
displayName: string;
avatarUrl?: string;
status: UserStatus;
role: UserRole;
joinedAt: number;
peerId?: string;
isOnline?: boolean;
isAdmin?: boolean;
isRoomOwner?: boolean;
voiceState?: VoiceState;
screenShareState?: ScreenShareState;
}
export interface RoomMember {
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: UserRole;
joinedAt: number;
lastSeenAt: number;
}
export interface Channel {
id: string;
name: string;
type: ChannelType;
position: number;
}
export interface Message {
id: string;
roomId: string;
channelId?: string;
senderId: string;
senderName: string;
content: string;
timestamp: number;
editedAt?: number;
reactions: Reaction[];
isDeleted: boolean;
replyToId?: string;
}
export interface Reaction {
id: string;
messageId: string;
oderId: string;
userId: string;
emoji: string;
timestamp: number;
}
export interface Room {
id: string;
name: string;
description?: string;
topic?: string;
hostId: string;
password?: string;
hasPassword?: boolean;
isPrivate: boolean;
createdAt: number;
userCount: number;
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
permissions?: RoomPermissions;
channels?: Channel[];
members?: RoomMember[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface RoomSettings {
name: string;
description?: string;
topic?: string;
isPrivate: boolean;
password?: string;
hasPassword?: boolean;
maxUsers?: number;
rules?: string[];
}
export interface RoomPermissions {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
}
export interface BanEntry {
oderId: string;
userId: string;
roomId: string;
bannedBy: string;
displayName?: string;
reason?: string;
expiresAt?: number;
timestamp: number;
}
export interface PeerConnection {
peerId: string;
userId: string;
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
dataChannel?: RTCDataChannel;
connection?: RTCPeerConnection;
}
export interface VoiceState {
isConnected: boolean;
isMuted: boolean;
isDeafened: boolean;
isSpeaking: boolean;
isMutedByAdmin?: boolean;
volume?: number;
roomId?: string;
serverId?: string;
}
export interface ScreenShareState {
isSharing: boolean;
streamId?: string;
sourceId?: string;
sourceName?: string;
}
export type SignalingMessageType =
| 'offer'
| 'answer'
| 'ice-candidate'
| 'join'
| 'leave'
| 'chat'
| 'state-sync'
| 'kick'
| 'ban'
| 'host-change'
| 'room-update';
export interface SignalingMessage {
type: SignalingMessageType;
from: string;
to?: string;
payload: unknown;
timestamp: number;
}
export type ChatEventType =
| 'message'
| 'chat-message'
| 'edit'
| 'message-edited'
| 'delete'
| 'message-deleted'
| 'reaction'
| 'reaction-added'
| 'reaction-removed'
| 'kick'
| 'ban'
| 'room-deleted'
| 'host-change'
| 'room-settings-update'
| 'voice-state'
| 'chat-inventory-request'
| 'chat-inventory'
| 'chat-sync-request-ids'
| 'chat-sync-batch'
| 'chat-sync-summary'
| 'chat-sync-request'
| 'chat-sync-full'
| 'file-announce'
| 'file-chunk'
| 'file-request'
| 'file-cancel'
| 'file-not-found'
| 'member-roster-request'
| 'member-roster'
| 'member-leave'
| 'voice-state-request'
| 'state-request'
| 'screen-state'
| 'screen-share-request'
| 'screen-share-stop'
| 'role-change'
| 'room-permissions-update'
| 'server-icon-summary'
| 'server-icon-request'
| 'server-icon-full'
| 'server-icon-update'
| 'server-state-request'
| 'server-state-full'
| 'unban'
| 'channels-update';
export interface ChatInventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
}
export interface ChatAttachmentAnnouncement {
id: string;
filename: string;
size: number;
mime: string;
isImage: boolean;
uploaderPeerId?: string;
}
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
messageId: string;
filePath?: string;
savedPath?: string;
}
/** Optional fields depend on `type`. */
export interface ChatEvent {
type: ChatEventType;
fromPeerId?: string;
messageId?: string;
message?: Message;
reaction?: Reaction;
data?: string | Partial<Message>;
timestamp?: number;
targetUserId?: string;
roomId?: string;
items?: ChatInventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: Record<string, ChatAttachmentMeta[]>;
total?: number;
index?: number;
count?: number;
lastUpdated?: number;
file?: ChatAttachmentAnnouncement;
fileId?: string;
hostId?: string;
hostOderId?: string;
previousHostId?: string;
previousHostOderId?: string;
kickedBy?: string;
bannedBy?: string;
content?: string;
editedAt?: number;
deletedAt?: number;
deletedBy?: string;
oderId?: string;
displayName?: string;
emoji?: string;
reason?: string;
settings?: Partial<RoomSettings>;
permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean;
icon?: string;
iconUpdatedAt?: number;
role?: UserRole;
room?: Partial<Room>;
channels?: Channel[];
members?: RoomMember[];
ban?: BanEntry;
bans?: BanEntry[];
banOderId?: string;
expiresAt?: number;
}
export interface ServerInfo {
id: string;
name: string;
description?: string;
topic?: string;
hostName: string;
ownerId?: string;
ownerName?: string;
ownerPublicKey?: string;
userCount: number;
maxUsers: number;
hasPassword?: boolean;
isPrivate: boolean;
tags?: string[];
createdAt: number;
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface JoinRequest {
roomId: string;
userId: string;
username: string;
}
export interface AppState {
currentUser: User | null;
currentRoom: Room | null;
isConnecting: boolean;
error: string | null;
}

View File

@@ -1,23 +0,0 @@
const ROOM_NAME_SANITIZER = /[^\w.-]+/g;
export function sanitizeAttachmentRoomName(roomName: string): string {
const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_');
return sanitizedRoomName || 'room';
}
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
if (mime.startsWith('video/')) {
return 'video';
}
if (mime.startsWith('audio/')) {
return 'audio';
}
if (mime.startsWith('image/')) {
return 'image';
}
return 'files';
}

View File

@@ -1 +0,0 @@
export * from './application/klipy.service';

View File

@@ -1,5 +0,0 @@
export {
LATENCY_PROFILE_BITRATES,
type LatencyProfile
} from '../../../infrastructure/realtime/realtime.constants';
export type { VoiceStateSnapshot } from '../../../infrastructure/realtime/realtime.types';

View File

@@ -1,4 +0,0 @@
export * from './application/voice-session.facade';
export * from './application/voice-workspace.service';
export * from './domain/voice-session.models';
export * from './infrastructure/voice-settings.storage';

View File

@@ -1,47 +0,0 @@
<div class="space-y-6 max-w-xl">
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucidePower"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Application</h4>
</div>
<div
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
[class.opacity-60]="!isElectron"
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
@if (isElectron) {
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
} @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
}
</div>
<label
class="relative inline-flex items-center"
[class.cursor-pointer]="isElectron && !savingAutoStart()"
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
>
<input
type="checkbox"
[checked]="autoStart()"
[disabled]="!isElectron || savingAutoStart()"
(change)="onAutoStartChange($event)"
id="general-auto-start-toggle"
aria-label="Toggle launch on startup"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
</section>
</div>

View File

@@ -1,9 +0,0 @@
import { User } from '../../../core/models';
export interface ScreenShareWorkspaceStreamItem {
id: string;
peerKey: string;
user: User;
stream: MediaStream;
isLocal: boolean;
}

View File

@@ -1 +0,0 @@
export * from '../../domains/screen-share/domain/screen-share.config';

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "../node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm", "packageManager": "npm",
@@ -62,27 +62,28 @@
], ],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss",
"node_modules/prismjs/themes/prism-okaidia.css" "../node_modules/prismjs/themes/prism-okaidia.css"
], ],
"scripts": [ "scripts": [
"node_modules/prismjs/prism.js", "../node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-markup.min.js", "../node_modules/prismjs/components/prism-markup.min.js",
"node_modules/prismjs/components/prism-clike.min.js", "../node_modules/prismjs/components/prism-clike.min.js",
"node_modules/prismjs/components/prism-javascript.min.js", "../node_modules/prismjs/components/prism-javascript.min.js",
"node_modules/prismjs/components/prism-typescript.min.js", "../node_modules/prismjs/components/prism-typescript.min.js",
"node_modules/prismjs/components/prism-css.min.js", "../node_modules/prismjs/components/prism-css.min.js",
"node_modules/prismjs/components/prism-scss.min.js", "../node_modules/prismjs/components/prism-scss.min.js",
"node_modules/prismjs/components/prism-json.min.js", "../node_modules/prismjs/components/prism-json.min.js",
"node_modules/prismjs/components/prism-bash.min.js", "../node_modules/prismjs/components/prism-bash.min.js",
"node_modules/prismjs/components/prism-markdown.min.js", "../node_modules/prismjs/components/prism-markdown.min.js",
"node_modules/prismjs/components/prism-yaml.min.js", "../node_modules/prismjs/components/prism-yaml.min.js",
"node_modules/prismjs/components/prism-python.min.js", "../node_modules/prismjs/components/prism-python.min.js",
"node_modules/prismjs/components/prism-csharp.min.js" "../node_modules/prismjs/components/prism-csharp.min.js"
], ],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"simple-peer", "simple-peer",
"uuid" "uuid"
] ],
"outputPath": "../dist/client"
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -96,7 +97,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "1MB", "maximumWarning": "1MB",
"maximumError": "2MB" "maximumError": "2.1MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -13,6 +13,7 @@ import { routes } from './app.routes';
import { messagesReducer } from './store/messages/messages.reducer'; import { messagesReducer } from './store/messages/messages.reducer';
import { usersReducer } from './store/users/users.reducer'; import { usersReducer } from './store/users/users.reducer';
import { roomsReducer } from './store/rooms/rooms.reducer'; import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UsersEffects } from './store/users/users.effects'; import { UsersEffects } from './store/users/users.effects';
@@ -32,6 +33,7 @@ export const appConfig: ApplicationConfig = {
rooms: roomsReducer rooms: roomsReducer
}), }),
provideEffects([ provideEffects([
NotificationsEffects,
MessagesEffects, MessagesEffects,
MessagesSyncEffects, MessagesSyncEffects,
UsersEffects, UsersEffects,

View File

@@ -10,22 +10,22 @@ export const routes: Routes = [
{ {
path: 'login', path: 'login',
loadComponent: () => loadComponent: () =>
import('./features/auth/login/login.component').then((module) => module.LoginComponent) import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent)
}, },
{ {
path: 'register', path: 'register',
loadComponent: () => loadComponent: () =>
import('./features/auth/register/register.component').then((module) => module.RegisterComponent) import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent)
}, },
{ {
path: 'invite/:inviteId', path: 'invite/:inviteId',
loadComponent: () => loadComponent: () =>
import('./features/invite/invite.component').then((module) => module.InviteComponent) import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent)
}, },
{ {
path: 'search', path: 'search',
loadComponent: () => loadComponent: () =>
import('./features/server-search/server-search.component').then( import('./domains/server-directory/feature/server-search/server-search.component').then(
(module) => module.ServerSearchComponent (module) => module.ServerSearchComponent
) )
}, },

View File

@@ -17,6 +17,7 @@ import { Store } from '@ngrx/store';
import { DatabaseService } from './infrastructure/persistence'; import { DatabaseService } from './infrastructure/persistence';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { ServerDirectoryFacade } from './domains/server-directory'; import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service'; import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionFacade } from './domains/voice-session'; import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform'; import { ExternalLinkService } from './core/platform';
@@ -24,7 +25,7 @@ import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { ServersRailComponent } from './features/servers/servers-rail.component'; import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component'; import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.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 { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.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 { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
@@ -61,6 +62,7 @@ export class App implements OnInit, OnDestroy {
private databaseService = inject(DatabaseService); private databaseService = inject(DatabaseService);
private router = inject(Router); private router = inject(Router);
private servers = inject(ServerDirectoryFacade); private servers = inject(ServerDirectoryFacade);
private notifications = inject(NotificationsFacade);
private settingsModal = inject(SettingsModalService); private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService); private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionFacade); private voiceSession = inject(VoiceSessionFacade);
@@ -84,6 +86,8 @@ export class App implements OnInit, OnDestroy {
await this.timeSync.syncWithEndpoint(apiBase); await this.timeSync.syncWithEndpoint(apiBase);
} catch {} } catch {}
await this.notifications.initialize();
await this.setupDesktopDeepLinks(); await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(UsersActions.loadCurrentUser());

View File

@@ -1,6 +1,7 @@
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId'; export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute'; export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; 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_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings'; export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';

View File

@@ -0,0 +1,52 @@
/**
* Transitional compatibility barrel.
*
* All business types now live in `src/app/shared-kernel/` (organised by concept)
* or in their owning domain. This file re-exports everything so existing
* `import { X } from 'core/models'` lines keep working while the codebase
* migrates to direct shared-kernel imports.
*
* NEW CODE should import from `@shared-kernel` or the owning domain barrel
* instead of this file.
*/
export type {
User,
UserStatus,
UserRole,
RoomMember
} from '../../shared-kernel';
export type {
Room,
RoomSettings,
RoomPermissions,
Channel,
ChannelType
} from '../../shared-kernel';
export type { Message, Reaction } from '../../shared-kernel';
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
export type { BanEntry } from '../../shared-kernel';
export type { VoiceState, ScreenShareState } from '../../shared-kernel';
export type {
ChatEventBase,
ChatEventType,
ChatEvent,
ChatInventoryItem
} from '../../shared-kernel';
export type {
SignalingMessage,
SignalingMessageType
} from '../../shared-kernel';
export type {
ChatAttachmentAnnouncement,
ChatAttachmentMeta
} from '../../shared-kernel';
export type { ServerInfo } from '../../domains/server-directory';

View File

@@ -57,6 +57,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState { export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
availableVersions: string[]; availableVersions: string[];
@@ -83,6 +89,7 @@ export interface DesktopUpdateState {
export interface DesktopSettingsSnapshot { export interface DesktopSettingsSnapshot {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
autoStart: boolean; autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -93,12 +100,24 @@ export interface DesktopSettingsSnapshot {
export interface DesktopSettingsPatch { export interface DesktopSettingsPatch {
autoUpdateMode?: AutoUpdateMode; autoUpdateMode?: AutoUpdateMode;
autoStart?: boolean; autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean; hardwareAcceleration?: boolean;
manifestUrls?: string[]; manifestUrls?: string[];
preferredVersion?: string | null; preferredVersion?: string | null;
vaapiVideoEncode?: boolean; vaapiVideoEncode?: boolean;
} }
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
export interface ElectronCommand { export interface ElectronCommand {
type: string; type: string;
payload: unknown; payload: unknown;
@@ -126,7 +145,12 @@ export interface ElectronApi {
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
consumePendingDeepLink: () => Promise<string | null>; consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>; getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>; getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>; configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>; checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>; restartToApplyUpdate: () => Promise<boolean>;

View File

@@ -1,3 +1,8 @@
/**
* Transitional application-facing boundary over the shared realtime runtime.
* Keep business domains depending on this technical API rather than reaching
* into low-level infrastructure implementations directly.
*/
export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service'; export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service';
export * from '../../infrastructure/realtime/realtime.constants'; export * from '../../infrastructure/realtime/realtime.constants';
export * from '../../infrastructure/realtime/realtime.types'; export * from '../../infrastructure/realtime/realtime.types';

View File

@@ -433,7 +433,7 @@ class DebugNetworkSnapshotBuilder {
} }
} }
if (type === 'screen-state') { if (type === 'screen-state' || type === 'camera-state') {
const subjectNode = direction === 'outbound' const subjectNode = direction === 'outbound'
? this.ensureLocalNetworkNode( ? this.ensureLocalNetworkNode(
state, state,
@@ -442,12 +442,14 @@ class DebugNetworkSnapshotBuilder {
this.getPayloadString(payload, 'displayName') this.getPayloadString(payload, 'displayName')
) )
: peerNode; : peerNode;
const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing'); const isStreaming = type === 'screen-state'
? this.getPayloadBoolean(payload, 'isScreenSharing')
: this.getPayloadBoolean(payload, 'isCameraEnabled');
if (isScreenSharing !== null) { if (isStreaming !== null) {
subjectNode.isStreaming = isScreenSharing; subjectNode.isStreaming = isStreaming;
if (!isScreenSharing) if (!isStreaming)
subjectNode.streams.video = 0; subjectNode.streams.video = 0;
} }
} }

View File

@@ -10,17 +10,13 @@ import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server
import { import {
type AutoUpdateMode, type AutoUpdateMode,
type DesktopUpdateServerContext, type DesktopUpdateServerContext,
type DesktopUpdateServerHealthSnapshot,
type DesktopUpdateServerVersionStatus, type DesktopUpdateServerVersionStatus,
type DesktopUpdateState, type DesktopUpdateState,
type ElectronApi type ElectronApi
} from '../platform/electron/electron-api.models'; } from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface ServerHealthSnapshot { interface ServerHealthSnapshot {
endpointId: string; endpointId: string;
manifestUrl: string | null; manifestUrl: string | null;
@@ -29,7 +25,6 @@ interface ServerHealthSnapshot {
} }
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000; const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
function createInitialState(): DesktopUpdateState { function createInitialState(): DesktopUpdateState {
return { return {
@@ -292,14 +287,9 @@ export class DesktopAppUpdateService {
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> { private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, ''); const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
const api = this.getElectronApi();
try { if (!api?.getAutoUpdateServerHealth) {
const response = await fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS)
});
if (!response.ok) {
return { return {
endpointId: endpoint.id, endpointId: endpoint.id,
manifestUrl: null, manifestUrl: null,
@@ -308,14 +298,12 @@ export class DesktopAppUpdateService {
}; };
} }
const payload = await response.json() as ServerHealthResponse; try {
const serverVersion = normalizeOptionalString(payload.serverVersion); const payload = await api.getAutoUpdateServerHealth(sanitizedServerUrl);
return { return {
endpointId: endpoint.id, endpointId: endpoint.id,
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl), ...this.normalizeHealthSnapshot(payload)
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
}; };
} catch { } catch {
return { return {
@@ -327,6 +315,22 @@ 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> { private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
const api = this.getElectronApi(); const api = this.getElectronApi();

View File

@@ -13,7 +13,7 @@ export enum AppSound {
} }
/** Path prefix for audio assets (served from the `assets/audio/` folder). */ /** 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. */ /** File extension used for all sound-effect assets. */
const AUDIO_EXT = 'wav'; const AUDIO_EXT = 'wav';
/** localStorage key for persisting notification volume. */ /** localStorage key for persisting notification volume. */
@@ -36,6 +36,8 @@ export class NotificationAudioService {
/** Pre-loaded audio buffers keyed by {@link AppSound}. */ /** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>(); private readonly cache = new Map<AppSound, HTMLAudioElement>();
private readonly sources = new Map<AppSound, string>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
@@ -46,13 +48,22 @@ export class NotificationAudioService {
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
private preload(): void { private preload(): void {
for (const sound of Object.values(AppSound)) { for (const sound of Object.values(AppSound)) {
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`); const src = this.resolveAudioUrl(sound);
const audio = new Audio();
audio.preload = 'auto'; audio.preload = 'auto';
audio.src = src;
audio.load();
this.sources.set(sound, src);
this.cache.set(sound, audio); 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. */ /** Read persisted volume from localStorage, falling back to the default. */
private loadVolume(): number { private loadVolume(): number {
try { try {
@@ -96,8 +107,9 @@ export class NotificationAudioService {
*/ */
play(sound: AppSound, volumeOverride?: number): void { play(sound: AppSound, volumeOverride?: number): void {
const cached = this.cache.get(sound); const cached = this.cache.get(sound);
const src = this.sources.get(sound);
if (!cached) if (!cached || !src)
return; return;
const vol = volumeOverride ?? this.notificationVolume(); const vol = volumeOverride ?? this.notificationVolume();
@@ -105,12 +117,23 @@ export class NotificationAudioService {
if (vol === 0) if (vol === 0)
return; // skip playback when muted return; // skip playback when muted
if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) {
cached.load();
}
// Clone so overlapping plays don't cut each other off. // Clone so overlapping plays don't cut each other off.
const clone = cached.cloneNode(true) as HTMLAudioElement; const clone = cached.cloneNode(true) as HTMLAudioElement;
clone.preload = 'auto';
clone.volume = Math.max(0, Math.min(1, vol)); clone.volume = Math.max(0, Math.min(1, vol));
clone.play().catch(() => { clone.play().catch(() => {
const fallback = new Audio(src);
fallback.preload = 'auto';
fallback.volume = clone.volume;
fallback.play().catch(() => {
/* swallow autoplay errors */ /* swallow autoplay errors */
}); });
});
} }
} }

View File

@@ -1,5 +1,16 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
export type SettingsPage =
| 'general'
| 'network'
| 'notifications'
| 'voice'
| 'updates'
| 'debugging'
| 'server'
| 'members'
| 'bans'
| 'permissions';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsModalService { export class SettingsModalService {

View File

@@ -0,0 +1,76 @@
# Domains
Each folder below is a **bounded context** — a self-contained slice of
business logic with its own models, application services, and (optionally)
infrastructure adapters and UI.
## Quick reference
| Domain | Purpose | Public entry point |
|---|---|---|
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## Detailed docs
The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md)
- [auth/README.md](auth/README.md)
- [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md)
- [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md)
- [voice-session/README.md](voice-session/README.md)
## Folder convention
Every domain follows the same internal layout:
```
domains/<name>/
├── index.ts # Barrel — the ONLY file outsiders import
├── domain/ # Pure types, interfaces, business rules
│ ├── <name>.models.ts
│ └── <name>.logic.ts # Pure functions (no Angular, no side effects)
├── application/ # Angular services that orchestrate domain logic
│ └── <name>.facade.ts # Public entry point for the domain
├── infrastructure/ # Technical adapters (HTTP, storage, WebSocket)
└── feature/ # Optional: domain-owned UI components / routes
└── settings/ # e.g. settings subpanel owned by this domain
```
## Rules
1. **Import from the barrel.** Outside a domain, always import from
`domains/<name>` (the `index.ts`), never from internal paths.
2. **No cross-domain imports.** Domain A must never import from Domain B's
internals. Shared types live in `shared-kernel/`.
3. **Features compose domains.** Top-level `features/` components inject
domain facades and compose their outputs — they never contain business
logic.
4. **Store slices are application-level.** `store/messages`, `store/rooms`,
`store/users` are global state managed by NgRx. They import from
`shared-kernel` for types and from domain facades for side-effects.
## Where do I put new code?
| I want to… | Put it in… |
|---|---|
| Add a new business concept | New folder under `domains/` following the convention above |
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
| Add a settings subpanel | `domains/<name>/feature/settings/` |
| Add a top-level page or shell component | `features/` |
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
| Add realtime/WebRTC logic | `infrastructure/realtime/` |

View File

@@ -0,0 +1,148 @@
# Attachment Domain
Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser).
## Module map
```
attachment/
├── application/
│ ├── attachment.facade.ts Thin entry point, delegates to manager
│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
├── domain/
│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state
│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...)
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
├── infrastructure/
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
└── index.ts Barrel exports
```
## Service composition
The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals).
```mermaid
graph TD
Facade[AttachmentFacade]
Manager[AttachmentManagerService]
Transfer[AttachmentTransferService]
Transport[AttachmentTransferTransportService]
Persistence[AttachmentPersistenceService]
Store[AttachmentRuntimeStore]
Storage[AttachmentStorageService]
Logic[attachment.logic]
Facade --> Manager
Manager --> Transfer
Manager --> Persistence
Manager --> Store
Manager --> Logic
Transfer --> Transport
Transfer --> Store
Persistence --> Storage
Persistence --> Store
Storage --> Helpers[attachment-storage.helpers]
click Facade "application/attachment.facade.ts" "Thin entry point" _blank
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank
```
## File transfer protocol
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
```mermaid
sequenceDiagram
participant S as Sender
participant R as Receiver
S->>R: file-announce (id, name, size, mimeType)
Note over R: Store metadata in runtime store
Note over R: shouldAutoRequestWhenWatched?
R->>S: file-request (attachmentId)
Note over S: Look up file in runtime store or on disk
loop Every 64 KB chunk
S->>R: file-chunk (attachmentId, index, data, progress, speed)
Note over R: Append to chunk buffer
Note over R: Update progress + EWMA speed
end
Note over R: All chunks received
Note over R: Reassemble blob
Note over R: shouldPersistDownloadedAttachment? Save to disk
```
### Failure handling
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
```mermaid
sequenceDiagram
participant R as Receiver
participant P1 as Peer A
participant P2 as Peer B
R->>P1: file-request
P1->>R: file-not-found
Note over R: Try next peer
R->>P2: file-request
P2->>R: file-chunk (1/N)
P2->>R: file-chunk (2/N)
P2->>R: file-chunk (N/N)
Note over R: Transfer complete
```
## Auto-download rules
When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic:
| Condition | Auto-download? |
|---|---|
| Image or video, size <= 10 MB | Yes |
| Image or video, size > 10 MB | No |
| Non-media file | No |
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
## Persistence
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
```
{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?}
```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
## Runtime store
`AttachmentRuntimeStore` is a signal-based in-memory store using `Map` instances for:
- **attachments**: all known attachments keyed by ID
- **chunks**: incoming chunk buffers during active transfers
- **pendingRequests**: outbound requests waiting for a response
- **cancellations**: IDs of transfers the user cancelled
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.

View File

@@ -1,4 +1,5 @@
import type { ChatAttachmentAnnouncement, ChatEvent } from '../../../core/models/index'; import type { ChatEvent } from '../../../shared-kernel';
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel';
export type FileAnnounceEvent = ChatEvent & { export type FileAnnounceEvent = ChatEvent & {
type: 'file-announce'; type: 'file-announce';

View File

@@ -1,4 +1,4 @@
import type { ChatAttachmentMeta } from '../../../core/models/index'; import type { ChatAttachmentMeta } from '../../../shared-kernel';
export type AttachmentMeta = ChatAttachmentMeta; export type AttachmentMeta = ChatAttachmentMeta;

View File

@@ -0,0 +1,43 @@
const ROOM_NAME_SANITIZER = /[^\w.-]+/g;
const STORED_FILENAME_SANITIZER = /[^\w.-]+/g;
export function sanitizeAttachmentRoomName(roomName: string): string {
const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_');
return sanitizedRoomName || 'room';
}
export function resolveAttachmentStoredFilename(attachmentId: string, filename: string): string {
const sanitizedAttachmentId = attachmentId.trim().replace(STORED_FILENAME_SANITIZER, '_') || 'attachment';
const basename = filename.trim().split(/[\\/]/)
.pop() ?? '';
const extensionIndex = basename.lastIndexOf('.');
if (extensionIndex <= 0 || extensionIndex === basename.length - 1) {
return sanitizedAttachmentId;
}
const sanitizedExtension = basename.slice(extensionIndex)
.replace(STORED_FILENAME_SANITIZER, '_')
.toLowerCase();
return sanitizedExtension === '.'
? sanitizedAttachmentId
: `${sanitizedAttachmentId}${sanitizedExtension}`;
}
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
if (mime.startsWith('video/')) {
return 'video';
}
if (mime.startsWith('audio/')) {
return 'audio';
}
if (mime.startsWith('image/')) {
return 'image';
}
return 'files';
}

View File

@@ -1,7 +1,11 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../domain/attachment.models'; import type { Attachment } from '../domain/attachment.models';
import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers'; import {
resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName
} from './attachment-storage.helpers';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentStorageService { export class AttachmentStorageService {
@@ -38,7 +42,7 @@ export class AttachmentStorageService {
} }
async saveBlob( async saveBlob(
attachment: Pick<Attachment, 'filename' | 'mime'>, attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
blob: Blob, blob: Blob,
roomName: string roomName: string
): Promise<string | null> { ): Promise<string | null> {
@@ -55,7 +59,7 @@ export class AttachmentStorageService {
await electronApi.ensureDir(directoryPath); await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const diskPath = `${directoryPath}/${attachment.filename}`; const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer)); await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));

View File

@@ -0,0 +1,74 @@
# Auth Domain
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
## Module map
```
auth/
├── application/
│ └── auth.service.ts HTTP login/register against the active server endpoint
├── feature/
│ ├── login/ Login form component
│ ├── register/ Registration form component
│ └── user-bar/ Displays current user or login/register links
└── index.ts Barrel exports
```
## Service overview
`AuthService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store.
```mermaid
graph TD
Login[LoginComponent]
Register[RegisterComponent]
UserBar[UserBarComponent]
Auth[AuthService]
SD[ServerDirectoryFacade]
Store[NgRx Store]
Login --> Auth
Register --> Auth
UserBar --> Store
Auth --> SD
Login --> Store
click Auth "application/auth.service.ts" "HTTP login/register" _blank
click Login "feature/login/" "Login form" _blank
click Register "feature/register/" "Registration form" _blank
click UserBar "feature/user-bar/" "Current user display" _blank
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL" _blank
```
## Login flow
```mermaid
sequenceDiagram
participant User
participant Login as LoginComponent
participant Auth as AuthService
participant SD as ServerDirectoryFacade
participant API as Server API
participant Store as NgRx Store
User->>Login: Submit credentials
Login->>Auth: login(username, password)
Auth->>SD: getApiBaseUrl()
SD-->>Auth: https://server/api
Auth->>API: POST /api/auth/login
API-->>Auth: { userId, displayName }
Auth-->>Login: success
Login->>Store: UsersActions.setCurrentUser
Login->>Login: localStorage.setItem(currentUserId)
```
## Registration flow
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens.
## User bar
`UserBarComponent` reads the current user from the NgRx store. When logged in it shows the user's display name; when not logged in it shows links to the login and register views.

View File

@@ -11,11 +11,11 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide'; import { lucideLogIn } from '@ng-icons/lucide';
import { AuthService } from '../../../domains/auth'; import { AuthService } from '../../application/auth.service';
import { ServerDirectoryFacade } from '../../../domains/server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../core/models/index'; import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',

View File

@@ -11,11 +11,11 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide'; import { lucideUserPlus } from '@ng-icons/lucide';
import { AuthService } from '../../../domains/auth'; import { AuthService } from '../../application/auth.service';
import { ServerDirectoryFacade } from '../../../domains/server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../core/models/index'; import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({ @Component({
selector: 'app-register', selector: 'app-register',

View File

@@ -8,7 +8,7 @@ import {
lucideLogIn, lucideLogIn,
lucideUserPlus lucideUserPlus
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
@Component({ @Component({
selector: 'app-user-bar', selector: 'app-user-bar',

View File

@@ -0,0 +1,149 @@
# Chat Domain
Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
## Module map
```
chat/
├── application/
│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
├── domain/
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop
│ │ ├── components/
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting
│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview
│ │ ├── models/ View models for messages
│ │ └── services/
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
│ │
│ ├── klipy-gif-picker/ GIF search/browse picker panel
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
│ └── user-list/ Online user sidebar
└── index.ts Barrel exports
```
## Component composition
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF.
```mermaid
graph TD
Chat[ChatMessagesComponent]
List[MessageListComponent]
Composer[MessageComposerComponent]
Overlays[MessageOverlays]
Item[MessageItemComponent]
GIF[KlipyGifPickerComponent]
Typing[TypingIndicatorComponent]
Users[UserListComponent]
Chat --> List
Chat --> Composer
Chat --> Overlays
Chat --> GIF
List --> Item
Item --> Overlays
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view" _blank
click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank
click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank
click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank
click Typing "feature/typing-indicator/" "Typing indicator" _blank
click Users "feature/user-list/" "Online user sidebar" _blank
```
## Message lifecycle
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations.
```mermaid
sequenceDiagram
participant User
participant Composer as MessageComposer
participant Store as NgRx Store
participant DC as Data Channel
participant Peer as Remote Peer
User->>Composer: Type + send
Composer->>Store: dispatch addMessage
Composer->>DC: broadcastMessage(chat-message)
DC->>Peer: chat-message event
Note over User: Edit
User->>Store: dispatch editMessage
User->>DC: broadcastMessage(edit-message)
Note over User: Delete
User->>Store: dispatch deleteMessage (normaliseDeletedMessage)
User->>DC: broadcastMessage(delete-message)
```
## Text channel scoping
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing.
If a room has no text channels, the room shell in `features/room/chat-room/` renders an empty state instead of mounting the chat view. The chat domain only mounts once a valid text channel exists.
## Message sync
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
```mermaid
sequenceDiagram
participant A as Peer A
participant B as Peer B
A->>B: inventory (up to 1000 msg IDs + timestamps)
B->>B: findMissingIds(remote, local)
B->>A: request missing message IDs
A->>B: message payloads (chunked, 200/batch)
```
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
## GIF integration
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Rendered remote images now attempt a direct load first and only fall back to the image proxy after the browser reports a load failure, which is the practical approximation of a CORS or mixed-content fallback path in the renderer.
```mermaid
graph LR
Picker[KlipyGifPickerComponent]
Klipy[KlipyService]
SD[ServerDirectoryFacade]
API[Server API]
Picker --> Klipy
Klipy --> SD
Klipy --> API
click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
```
## Domain rules
| Function | Purpose |
|---|---|
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -135,6 +135,10 @@ export class KlipyService {
} }
buildRenderableImageUrl(url: string): string { buildRenderableImageUrl(url: string): string {
return this.normalizeMediaUrl(url);
}
buildImageProxyUrl(url: string): string {
const trimmed = this.normalizeMediaUrl(url); const trimmed = this.normalizeMediaUrl(url);
if (!trimmed) if (!trimmed)

View File

@@ -0,0 +1,59 @@
/** Maximum number of recent messages to include in sync inventories. */
export const INVENTORY_LIMIT = 1000;
/** Number of messages per chunk for inventory / batch transfers. */
export const CHUNK_SIZE = 200;
/** Aggressive sync poll interval (10 seconds). */
export const SYNC_POLL_FAST_MS = 10_000;
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
export const SYNC_POLL_SLOW_MS = 900_000;
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
export const SYNC_TIMEOUT_MS = 5_000;
/** Large limit used for legacy full-sync operations. */
export const FULL_SYNC_LIMIT = 10_000;
/** Inventory item representing a message's sync state. */
export interface InventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
}
/** Splits an array into chunks of the given size. */
export function chunkArray<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds(
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
): string[] {
const missing: string[] = [];
for (const item of remoteItems) {
const local = localMap.get(item.id);
if (
!local ||
item.ts > local.ts ||
(item.rc !== undefined && item.rc !== local.rc) ||
(item.ac !== undefined && item.ac !== local.ac)
) {
missing.push(item.id);
}
}
return missing;
}

View File

@@ -0,0 +1,31 @@
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel';
/** Extracts the effective timestamp from a message (editedAt takes priority). */
export function getMessageTimestamp(msg: Message): number {
return msg.editedAt || msg.timestamp || 0;
}
/** Computes the most recent timestamp across a batch of messages. */
export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce(
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
0
);
}
/** Strips sensitive content from a deleted message. */
export function normaliseDeletedMessage(message: Message): Message {
if (!message.isDeleted)
return message;
return {
...message,
content: DELETED_MESSAGE_CONTENT,
reactions: []
};
}
/** Whether the given user is allowed to edit this message. */
export function canEditMessage(message: Message, userId: string): boolean {
return message.senderId === userId;
}

View File

@@ -0,0 +1,50 @@
import {
Directive,
HostBinding,
HostListener,
effect,
inject,
input,
signal
} from '@angular/core';
import { KlipyService } from '../application/klipy.service';
@Directive({
selector: 'img[appChatImageProxyFallback]',
standalone: true
})
export class ChatImageProxyFallbackDirective {
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
private readonly klipy = inject(KlipyService);
private readonly renderedSource = signal('');
private hasAppliedProxyFallback = false;
constructor() {
effect(() => {
this.hasAppliedProxyFallback = false;
this.renderedSource.set(this.klipy.buildRenderableImageUrl(this.sourceUrl()));
});
}
@HostBinding('src')
get src(): string {
return this.renderedSource();
}
@HostListener('error')
handleError(): void {
if (this.hasAppliedProxyFallback) {
return;
}
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
if (!proxyUrl || proxyUrl === this.renderedSource()) {
return;
}
this.hasAppliedProxyFallback = true;
this.renderedSource.set(proxyUrl);
}
}

View File

@@ -8,19 +8,19 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../domains/attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif } from '../../../domains/chat'; import { KlipyGif } from '../../application/klipy.service';
import { MessagesActions } from '../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { import {
selectAllMessages, selectAllMessages,
selectMessagesLoading, selectMessagesLoading,
selectMessagesSyncing selectMessagesSyncing
} from '../../../store/messages/messages.selectors'; } from '../../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models'; import { Message } from '../../../../shared-kernel';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
@@ -108,8 +108,18 @@ export class ChatMessagesComponent {
} }
handleTypingStarted(): void { handleTypingStarted(): void {
const roomId = this.currentRoom()?.id;
if (!roomId) {
return;
}
try { try {
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId }); this.webrtc.sendRawMessage({
type: 'typing',
serverId: roomId,
channelId: this.activeChannelId() ?? 'general'
});
} catch { } catch {
/* ignore */ /* ignore */
} }

View File

@@ -206,7 +206,7 @@
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2"> <div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80"> <div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img <img
[src]="getPendingKlipyGifPreviewUrl()" [appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'" [alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover" class="h-full w-full object-cover"
loading="lazy" loading="lazy"

Some files were not shown because too many files have changed in this diff Show More