ddd test 2

This commit is contained in:
2026-03-23 00:23:56 +01:00
parent fe9c1dd1c0
commit 971a5afb8b
130 changed files with 2493 additions and 822 deletions

4
.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

View File

@@ -1,3 +1,6 @@
<img src="./images/icon.png" width="100" height="100">
# Toju / Zoracord # Toju / Zoracord
Desktop chat app with three parts: Desktop chat app with three parts:
@@ -6,12 +9,6 @@ Desktop chat app with three parts:
- `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
## 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
1. Run `npm install` 1. Run `npm install`
@@ -58,3 +55,7 @@ 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">

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

@@ -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

@@ -24,7 +24,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';

View File

@@ -1,320 +1,54 @@
export type UserStatus = 'online' | 'away' | 'busy' | 'offline'; /**
* 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 UserRole = 'host' | 'admin' | 'moderator' | 'member'; // ── shared-kernel re-exports ────────────────────────────────────────
export type {
User,
UserStatus,
UserRole,
RoomMember
} from '../../shared-kernel';
export type ChannelType = 'text' | 'voice'; export type {
Room,
RoomSettings,
RoomPermissions,
Channel,
ChannelType
} from '../../shared-kernel';
export const DELETED_MESSAGE_CONTENT = '[Message deleted]'; export type { Message, Reaction } from '../../shared-kernel';
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
export interface User { export type { BanEntry } from '../../shared-kernel';
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 { export type { VoiceState, ScreenShareState } from '../../shared-kernel';
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: UserRole;
joinedAt: number;
lastSeenAt: number;
}
export interface Channel { export type {
id: string; ChatEventBase,
name: string; ChatEventType,
type: ChannelType; ChatEvent,
position: number; ChatInventoryItem
} } from '../../shared-kernel';
export interface Message { export type {
id: string; SignalingMessage,
roomId: string; SignalingMessageType
channelId?: string; } from '../../shared-kernel';
senderId: string;
senderName: string;
content: string;
timestamp: number;
editedAt?: number;
reactions: Reaction[];
isDeleted: boolean;
replyToId?: string;
}
export interface Reaction { export type {
id: string; ChatAttachmentAnnouncement,
messageId: string; ChatAttachmentMeta
oderId: string; } from '../../shared-kernel';
userId: string;
emoji: string;
timestamp: number;
}
export interface Room { // ── domain re-exports ───────────────────────────────────────────────
id: string; export type { ServerInfo } from '../../domains/server-directory';
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,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';

62
src/app/domains/README.md Normal file
View File

@@ -0,0 +1,62 @@
# 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` |
| **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 | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## 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"
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle"
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol"
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming"
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence"
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state"
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access"
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers"
click Logic "domain/attachment.logic.ts" "Pure decision functions"
```
## 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}/{filename}
```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type.
`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,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"
click Login "feature/login/" "Login form"
click Register "feature/register/" "Registration form"
click UserBar "feature/user-bar/" "Current user display"
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL"
```
## 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,143 @@
# 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"
click List "feature/chat-messages/components/message-list/" "Paginated message list"
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send"
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker"
click Item "feature/chat-messages/components/message-item/" "Single message bubble"
click GIF "feature/klipy-gif-picker/" "GIF search panel"
click Typing "feature/typing-indicator/" "Typing indicator"
click Users "feature/user-list/" "Online user sidebar"
```
## 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)
```
## 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. Images are rendered via an image proxy endpoint to avoid mixed-content issues.
```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"
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API"
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL"
```
## 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. Each event resets a 3-second TTL timer. 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

@@ -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

@@ -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';

View File

@@ -19,10 +19,10 @@ import {
lucideSend, lucideSend,
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import type { ClipboardFilePayload } from '../../../../../core/platform/electron/electron-api.models'; import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../../domains/chat'; import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
import { Message } from '../../../../../core/models'; import { Message } from '../../../../../../shared-kernel';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component'; import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service'; import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models'; import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';

View File

@@ -33,14 +33,14 @@ import {
Attachment, Attachment,
AttachmentFacade, AttachmentFacade,
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../domains/attachment'; } from '../../../../../attachment';
import { KlipyService } from '../../../../../domains/chat'; import { KlipyService } from '../../../../application/klipy.service';
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models'; import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
UserAvatarComponent UserAvatarComponent
} from '../../../../../shared'; } from '../../../../../../shared';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,

View File

@@ -12,8 +12,8 @@ import {
output, output,
signal signal
} from '@angular/core'; } from '@angular/core';
import { Attachment } from '../../../../../domains/attachment'; import { Attachment } from '../../../../../attachment';
import { Message } from '../../../../../core/models'; import { Message } from '../../../../../../shared-kernel';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,

View File

@@ -10,8 +10,8 @@ import {
lucideDownload, lucideDownload,
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { Attachment } from '../../../../../domains/attachment'; import { Attachment } from '../../../../../attachment';
import { ContextMenuComponent } from '../../../../../shared'; import { ContextMenuComponent } from '../../../../../../shared';
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models'; import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models';
@Component({ @Component({

View File

@@ -1,5 +1,5 @@
import { Attachment } from '../../../../domains/attachment'; import { Attachment } from '../../../../attachment';
import { Message } from '../../../../core/models'; import { Message } from '../../../../../shared-kernel';
export interface ChatMessageComposerSubmitEvent { export interface ChatMessageComposerSubmitEvent {
content: string; content: string;

View File

@@ -20,7 +20,7 @@ import {
lucideSearch, lucideSearch,
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../../domains/chat'; import { KlipyGif, KlipyService } from '../../application/klipy.service';
@Component({ @Component({
selector: 'app-klipy-gif-picker', selector: 'app-klipy-gif-picker',

View File

@@ -8,8 +8,8 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { import {
merge, merge,
interval, interval,

View File

@@ -22,14 +22,14 @@ import {
lucideVolumeX lucideVolumeX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { import {
selectOnlineUsers, selectOnlineUsers,
selectCurrentUser, selectCurrentUser,
selectIsCurrentUserAdmin selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors'; } from '../../../../store/users/users.selectors';
import { User } from '../../../core/models/index'; import { User } from '../../../../shared-kernel';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
@Component({ @Component({
selector: 'app-user-list', selector: 'app-user-list',

View File

@@ -1 +1,7 @@
export * from './application/klipy.service'; export * from './application/klipy.service';
export * from './domain/message.rules';
export * from './domain/message-sync.rules';
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
export { UserListComponent } from './feature/user-list/user-list.component';

View File

@@ -0,0 +1,137 @@
# Screen Share Domain
Manages screen sharing sessions, source selection (Electron), quality presets, and the viewer/workspace UI. Like `voice-connection`, the actual WebRTC track distribution lives in `infrastructure/realtime`; this domain provides the application-facing API and UI components.
## Module map
```
screen-share/
├── application/
│ ├── screen-share.facade.ts Proxy to RealtimeSessionFacade for screen share signals and methods
│ └── screen-share-source-picker.service.ts Electron desktop source picker (Promise-based open/confirm/cancel)
├── domain/
│ └── screen-share.config.ts Quality presets and types (re-exported from shared-kernel)
├── feature/
│ ├── screen-share-viewer/ Single-stream video player with fullscreen + volume
│ └── screen-share-workspace/ Multi-stream grid workspace
│ ├── screen-share-workspace.component.ts Grid layout, featured/thumbnail streams, mini-window mode
│ ├── screen-share-stream-tile.component.ts Individual stream tile with fullscreen/volume controls
│ ├── screen-share-playback.service.ts Per-user mute/volume state for screen share audio
│ └── screen-share-workspace.models.ts ScreenShareWorkspaceStreamItem
└── index.ts Barrel exports
```
## Service relationships
```mermaid
graph TD
SSF[ScreenShareFacade]
Picker[ScreenShareSourcePickerService]
RSF[RealtimeSessionFacade]
Config[screen-share.config]
Viewer[ScreenShareViewerComponent]
Workspace[ScreenShareWorkspaceComponent]
Tile[ScreenShareStreamTileComponent]
Playback[ScreenSharePlaybackService]
SSF --> RSF
Viewer --> SSF
Workspace --> SSF
Workspace --> Playback
Workspace --> Tile
Picker --> Config
click SSF "application/screen-share.facade.ts" "Proxy to RealtimeSessionFacade"
click Picker "application/screen-share-source-picker.service.ts" "Electron source picker"
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root"
click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player"
click Workspace "feature/screen-share-workspace/screen-share-workspace.component.ts" "Multi-stream workspace"
click Tile "feature/screen-share-workspace/screen-share-stream-tile.component.ts" "Stream tile"
click Playback "feature/screen-share-workspace/screen-share-playback.service.ts" "Per-user volume state"
click Config "domain/screen-share.config.ts" "Quality presets"
```
## Starting a screen share
```mermaid
sequenceDiagram
participant User
participant Controls as VoiceControls
participant Facade as ScreenShareFacade
participant Realtime as RealtimeSessionFacade
participant Picker as SourcePickerService
User->>Controls: Click "Share Screen"
alt Electron
Controls->>Picker: open(sources)
Picker-->>Controls: selected source + includeSystemAudio
end
Controls->>Facade: startScreenShare(options)
Facade->>Realtime: startScreenShare(options)
Note over Realtime: Captures screen via platform strategy
Note over Realtime: Waits for SCREEN_SHARE_REQUEST from viewers
Realtime-->>Facade: MediaStream
User->>Controls: Click "Stop"
Controls->>Facade: stopScreenShare()
Facade->>Realtime: stopScreenShare()
```
## Source picker (Electron)
`ScreenShareSourcePickerService` manages a Promise-based flow for Electron desktop capture. `open()` sets a signal with the available sources, and the UI renders a picker dialog. When the user selects a source, `confirm(sourceId, includeSystemAudio)` resolves the Promise. `cancel()` rejects with an `AbortError`.
Sources are classified as either `screen` or `window` based on the source ID prefix or name. The `includeSystemAudio` preference is persisted to voice settings storage.
## Quality presets
Screen share quality is configured through presets defined in the shared kernel:
| Preset | Resolution | Framerate |
|---|---|---|
| `low` | Reduced | Lower FPS |
| `balanced` | Medium | Medium FPS |
| `high` | Full | High FPS |
The quality dialog can be shown before each share (`askScreenShareQuality` setting) or skipped to use the last chosen preset.
## Viewer component
`ScreenShareViewerComponent` is a single-stream video player. It supports:
- Fullscreen toggle (browser Fullscreen API with CSS fallback)
- Volume control for remote streams (delegated to `VoicePlaybackService`)
- Local shares are always muted to avoid feedback
- Focus events from other components via a `viewer:focus` custom DOM event
- Auto-stop when the watched user stops sharing or the stream's video tracks end
## Workspace component
`ScreenShareWorkspaceComponent` is the multi-stream grid view inside the voice workspace panel. It handles:
- Listing all active screen shares (local + remote) sorted with remote first
- Featured/widescreen mode for a single focused stream with thumbnail sidebar
- Mini-window mode (draggable, position-clamped to viewport)
- Auto-hide header chrome in widescreen mode (2.2 s timeout, revealed on pointer move)
- On-demand remote stream requests via `syncRemoteScreenShareRequests`
- Per-stream volume and mute via `ScreenSharePlaybackService`
- Voice controls (mute, deafen, disconnect, share toggle) integrated into the workspace header
```mermaid
stateDiagram-v2
[*] --> Hidden
Hidden --> Expanded: open()
Expanded --> GridView: multiple shares, no focus
Expanded --> WidescreenView: single share or focused stream
WidescreenView --> GridView: showAllStreams()
GridView --> WidescreenView: focusShare(peerKey)
Expanded --> Minimized: minimize()
Minimized --> Expanded: restore()
Expanded --> Hidden: close()
Minimized --> Hidden: close()
```

View File

@@ -1,81 +1,21 @@
export type ScreenShareQuality = 'performance' | 'balanced' | 'high-fps' | 'quality'; import {
DEFAULT_SCREEN_SHARE_QUALITY,
DEFAULT_SCREEN_SHARE_START_OPTIONS,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
SCREEN_SHARE_QUALITY_PRESETS,
type ScreenShareQualityPreset,
type ScreenShareStartOptions,
type ScreenShareQuality
} from '../../../shared-kernel';
export interface ScreenShareStartOptions { export {
includeSystemAudio: boolean; DEFAULT_SCREEN_SHARE_QUALITY,
quality: ScreenShareQuality; DEFAULT_SCREEN_SHARE_START_OPTIONS,
} ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
export interface ScreenShareQualityPreset { SCREEN_SHARE_QUALITY_PRESETS,
label: string; type ScreenShareQualityPreset,
description: string; type ScreenShareStartOptions,
width: number; type ScreenShareQuality
height: number;
frameRate: number;
maxBitrateBps: number;
contentHint: 'motion' | 'detail';
degradationPreference: 'maintain-framerate' | 'maintain-resolution';
scaleResolutionDownBy?: number;
}
export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen';
export const DEFAULT_SCREEN_SHARE_QUALITY: ScreenShareQuality = 'balanced';
export const DEFAULT_SCREEN_SHARE_START_OPTIONS: ScreenShareStartOptions = {
includeSystemAudio: false,
quality: DEFAULT_SCREEN_SHARE_QUALITY
}; };
export const SCREEN_SHARE_QUALITY_PRESETS: Record<ScreenShareQuality, ScreenShareQualityPreset> = {
performance: {
label: 'Performance saver',
description: '720p / 30 FPS with lower CPU and bandwidth usage.',
width: 1280,
height: 720,
frameRate: 30,
maxBitrateBps: 2_000_000,
contentHint: 'motion',
degradationPreference: 'maintain-framerate',
scaleResolutionDownBy: 1
},
balanced: {
label: 'Balanced',
description: '1080p / 30 FPS for stable quality in most cases.',
width: 1920,
height: 1080,
frameRate: 30,
maxBitrateBps: 4_000_000,
contentHint: 'detail',
degradationPreference: 'maintain-resolution',
scaleResolutionDownBy: 1
},
'high-fps': {
label: 'High FPS',
description: '1080p / 60 FPS for games and fast motion.',
width: 1920,
height: 1080,
frameRate: 60,
maxBitrateBps: 6_000_000,
contentHint: 'motion',
degradationPreference: 'maintain-framerate',
scaleResolutionDownBy: 1
},
quality: {
label: 'Sharp text',
description: '1440p / 30 FPS for detailed UI and text clarity.',
width: 2560,
height: 1440,
frameRate: 30,
maxBitrateBps: 8_000_000,
contentHint: 'detail',
degradationPreference: 'maintain-resolution',
scaleResolutionDownBy: 1
}
};
export const SCREEN_SHARE_QUALITY_OPTIONS = (
Object.entries(SCREEN_SHARE_QUALITY_PRESETS) as [ScreenShareQuality, ScreenShareQualityPreset][]
).map(([id, preset]) => ({
id,
...preset
}));

View File

@@ -19,11 +19,11 @@ import {
lucideMonitor lucideMonitor
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ScreenShareFacade } from '../../../domains/screen-share'; import { ScreenShareFacade } from '../../application/screen-share.facade';
import { selectOnlineUsers } from '../../../store/users/users.selectors'; import { selectOnlineUsers } from '../../../../store/users/users.selectors';
import { User } from '../../../core/models/index'; import { User } from '../../../../shared-kernel';
import { DEFAULT_VOLUME } from '../../../core/constants'; import { DEFAULT_VOLUME } from '../../../../core/constants';
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service'; import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
@Component({ @Component({
selector: 'app-screen-share-viewer', selector: 'app-screen-share-viewer',

View File

@@ -21,7 +21,7 @@ import {
lucideVolumeX lucideVolumeX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../shared'; import { UserAvatarComponent } from '../../../../shared';
import { ScreenSharePlaybackService } from './screen-share-playback.service'; import { ScreenSharePlaybackService } from './screen-share-playback.service';
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models'; import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';

View File

@@ -29,25 +29,22 @@ import {
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { User } from '../../../core/models'; import { User } from '../../../../shared-kernel';
import { import {
loadVoiceSettingsFromStorage, loadVoiceSettingsFromStorage,
saveVoiceSettingsToStorage, saveVoiceSettingsToStorage,
VoiceSessionFacade, VoiceSessionFacade,
VoiceWorkspacePosition, VoiceWorkspacePosition,
VoiceWorkspaceService VoiceWorkspaceService
} from '../../../domains/voice-session'; } from '../../../../domains/voice-session';
import { VoiceConnectionFacade } from '../../../domains/voice-connection'; import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
ScreenShareFacade, import { ScreenShareFacade } from '../../application/screen-share.facade';
ScreenShareQuality, import { ScreenShareQuality, ScreenShareStartOptions } from '../../domain/screen-share.config';
ScreenShareStartOptions import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
} from '../../../domains/screen-share'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { UsersActions } from '../../../store/users/users.actions'; import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../../shared';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service';
import { ScreenSharePlaybackService } from './screen-share-playback.service'; import { ScreenSharePlaybackService } from './screen-share-playback.service';
import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component'; import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component';
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models'; import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';

View File

@@ -1,4 +1,4 @@
import { User } from '../../../core/models'; import { User } from '../../../../shared-kernel';
export interface ScreenShareWorkspaceStreamItem { export interface ScreenShareWorkspaceStreamItem {
id: string; id: string;

View File

@@ -1,3 +1,8 @@
export * from './application/screen-share.facade'; export * from './application/screen-share.facade';
export * from './application/screen-share-source-picker.service'; export * from './application/screen-share-source-picker.service';
export * from './domain/screen-share.config'; export * from './domain/screen-share.config';
// Feature components
export { ScreenShareViewerComponent } from './feature/screen-share-viewer/screen-share-viewer.component';
export { ScreenShareWorkspaceComponent } from './feature/screen-share-workspace/screen-share-workspace.component';
export { ScreenShareStreamTileComponent } from './feature/screen-share-workspace/screen-share-stream-tile.component';

View File

@@ -0,0 +1,176 @@
# Server Directory Domain
Manages the list of server endpoints the client can connect to, health-checking them, resolving API URLs, and providing server CRUD, search, invites, and moderation. This is the central domain that other domains (auth, chat, attachment) depend on for knowing where the backend is.
## Module map
```
server-directory/
├── application/
│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence
├── domain/
│ ├── server-directory.models.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types
│ ├── server-directory.constants.ts CLIENT_UPDATE_REQUIRED_MESSAGE
│ └── server-endpoint-defaults.ts Default endpoint templates, URL sanitisation, reconciliation helpers
├── infrastructure/
│ ├── server-directory-api.service.ts HTTP client for all server API calls
│ ├── server-endpoint-health.service.ts Health probe (GET /api/health with 5 s timeout, fallback to /api/servers)
│ ├── server-endpoint-compatibility.service.ts Semantic version comparison for client/server compatibility
│ └── server-endpoint-storage.service.ts localStorage read/write for endpoint list and removed-default tracking
├── feature/
│ ├── invite/ Invite creation and resolution UI
│ ├── server-search/ Server search/browse panel
│ └── settings/ Server endpoint management settings
└── index.ts Barrel exports
```
## Layer composition
The facade delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service.
```mermaid
graph TD
Facade[ServerDirectoryFacade]
State[ServerEndpointStateService]
API[ServerDirectoryApiService]
Health[ServerEndpointHealthService]
Compat[ServerEndpointCompatibilityService]
Storage[ServerEndpointStorageService]
Defaults[server-endpoint-defaults]
Models[server-directory.models]
Facade --> API
Facade --> State
Facade --> Health
Facade --> Compat
API --> State
State --> Storage
State --> Defaults
Health --> Compat
click Facade "application/server-directory.facade.ts" "High-level API"
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state"
click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API"
click Health "infrastructure/server-endpoint-health.service.ts" "Health probe"
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility"
click Storage "infrastructure/server-endpoint-storage.service.ts" "localStorage persistence"
click Defaults "domain/server-endpoint-defaults.ts" "Default endpoint templates"
click Models "domain/server-directory.models.ts" "Domain types"
```
## Endpoint lifecycle
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active.
```mermaid
stateDiagram-v2
[*] --> Load: constructor
Load --> HasStored: localStorage has endpoints
Load --> InitDefaults: no stored endpoints
InitDefaults --> Ready: save default endpoints
HasStored --> Reconcile: compare stored vs defaults
Reconcile --> Ready: merge, ensure active
Ready --> HealthCheck: facade.testAllServers()
state HealthCheck {
[*] --> Probing
Probing --> Online: /api/health 200 OK
Probing --> Incompatible: version mismatch
Probing --> Offline: request failed
}
```
## Health probing
The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which:
1. Sends `GET /api/health` with a 5-second timeout
2. On success, checks the response's `serverVersion` against the client version via `ServerEndpointCompatibilityService`
3. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
4. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
5. Updates the endpoint's status, latency, and version info in the state service
```mermaid
sequenceDiagram
participant Facade
participant Health as HealthService
participant Compat as CompatibilityService
participant API as Server
Facade->>Health: probeEndpoint(endpoint, clientVersion)
Health->>API: GET /api/health (5s timeout)
alt 200 OK
API-->>Health: { serverVersion }
Health->>Compat: evaluateServerVersion(serverVersion, clientVersion)
Compat-->>Health: { isCompatible, serverVersion }
Health-->>Facade: online / incompatible + latency + versions
else Request failed
Health->>API: GET /api/servers (fallback)
alt 200 OK
API-->>Health: servers list
Health-->>Facade: online + latency
else Also failed
Health-->>Facade: offline
end
end
Facade->>Facade: updateServerStatus(id, status, latency, versions)
```
## Server search
The facade's `searchServers(query)` method supports two modes controlled by a `searchAllServers` flag:
- **Single endpoint**: searches only the active server's API
- **All endpoints**: fans out the query to every online active endpoint via `forkJoin`, then deduplicates results by server ID
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
## Default endpoint management
Default servers are configured in the environment file. The state service builds `DefaultEndpointTemplate` objects from the configuration and uses them during reconciliation:
- Stored endpoints are matched to defaults by `defaultKey` or URL
- Missing defaults are added unless the user explicitly removed them (tracked in a separate localStorage key)
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
- The primary default URL is used as a fallback when no endpoint is resolved
URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol.
## Server administration
The facade provides methods for server registration, updates, and unregistration. These map directly to the API service's HTTP calls:
| Method | HTTP | Endpoint |
|---|---|---|
| `registerServer` | POST | `/api/servers` |
| `updateServer` | PUT | `/api/servers/:id` |
| `unregisterServer` | DELETE | `/api/servers/:id` |
## Invites and moderation
| Method | Purpose |
|---|---|
| `createInvite(serverId, request)` | Creates a time-limited invite link |
| `getInvite(inviteId)` | Resolves invite metadata |
| `requestServerAccess(request)` | Joins a server (via membership, password, invite, or public access) |
| `kickServerMember(serverId, request)` | Removes a user from the server |
| `banServerMember(serverId, request)` | Bans a user with optional reason and expiry |
| `unbanServerMember(serverId, request)` | Lifts a ban |
## Persistence
All endpoint state is persisted to localStorage under two keys:
| Key | Contents |
|---|---|
| `metoyou_server_endpoints` | Full `ServerEndpoint[]` array |
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.

View File

@@ -5,7 +5,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants'; import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
import { ServerInfo, User } from '../../../core/models'; import { User } from '../../../shared-kernel';
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants'; import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service'; import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
import type { import type {
@@ -14,6 +14,7 @@ import type {
KickServerMemberRequest, KickServerMemberRequest,
ServerEndpoint, ServerEndpoint,
ServerEndpointVersions, ServerEndpointVersions,
ServerInfo,
ServerInviteInfo, ServerInviteInfo,
ServerJoinAccessRequest, ServerJoinAccessRequest,
ServerJoinAccessResponse, ServerJoinAccessResponse,

View File

@@ -1,7 +1,25 @@
import type { ServerInfo } from '../../../core/models';
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible'; export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
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 ConfiguredDefaultServerDefinition { export interface ConfiguredDefaultServerDefinition {
key?: string; key?: string;
name?: string; name?: string;

View File

@@ -8,14 +8,14 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import type { ServerInviteInfo } from '../../domains/server-directory'; import type { ServerInviteInfo } from '../../domain/server-directory.models';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../domains/server-directory'; import { ServerDirectoryFacade } from '../../application/server-directory.facade';
import { User } from '../../core/models/index'; import { User } from '../../../../shared-kernel';
@Component({ @Component({
selector: 'app-invite', selector: 'app-invite',

View File

@@ -26,24 +26,21 @@ import {
lucideSettings lucideSettings
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { import {
selectSearchResults, selectSearchResults,
selectIsSearching, selectIsSearching,
selectRoomsError, selectRoomsError,
selectSavedRooms selectSavedRooms
} from '../../store/rooms/rooms.selectors'; } from '../../../../store/rooms/rooms.selectors';
import { import { Room, User } from '../../../../shared-kernel';
Room, import { SettingsModalService } from '../../../../core/services/settings-modal.service';
ServerInfo, import { DatabaseService } from '../../../../infrastructure/persistence';
User import { type ServerInfo } from '../../domain/server-directory.models';
} from '../../core/models/index'; import { ServerDirectoryFacade } from '../../application/server-directory.facade';
import { SettingsModalService } from '../../core/services/settings-modal.service'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { DatabaseService } from '../../infrastructure/persistence'; import { ConfirmDialogComponent } from '../../../../shared';
import { ServerDirectoryFacade } from '../../domains/server-directory'; import { hasRoomBanForUser } from '../../../../core/helpers/room-ban.helpers';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ConfirmDialogComponent } from '../../shared';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
@Component({ @Component({
selector: 'app-server-search', selector: 'app-server-search',

View File

@@ -8,13 +8,14 @@ import {
throwError throwError
} from 'rxjs'; } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { ServerInfo, User } from '../../../core/models'; import { User } from '../../../shared-kernel';
import { ServerEndpointStateService } from '../application/server-endpoint-state.service'; import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
import type { import type {
BanServerMemberRequest, BanServerMemberRequest,
CreateServerInviteRequest, CreateServerInviteRequest,
KickServerMemberRequest, KickServerMemberRequest,
ServerEndpoint, ServerEndpoint,
ServerInfo,
ServerInviteInfo, ServerInviteInfo,
ServerJoinAccessRequest, ServerJoinAccessRequest,
ServerJoinAccessResponse, ServerJoinAccessResponse,

View File

@@ -0,0 +1,97 @@
# Voice Connection Domain
Bridges the application layer to the low-level realtime infrastructure for voice calls. Provides speaking detection via Web Audio analysis and per-peer volume control for playback. The actual WebRTC plumbing lives in `infrastructure/realtime`; this domain wraps it with a clean facade.
## Module map
```
voice-connection/
├── application/
│ ├── voice-connection.facade.ts Proxy to RealtimeSessionFacade for voice signals and methods
│ ├── voice-activity.service.ts RMS-based speaking detection via AnalyserNode (per-user signals)
│ └── voice-playback.service.ts Per-peer GainNode chain, 0-200% volume, deafen support
├── domain/
│ └── voice-connection.models.ts Re-exports LatencyProfile, VoiceStateSnapshot from shared-kernel / realtime
└── index.ts Barrel exports
```
## Service relationships
```mermaid
graph TD
VCF[VoiceConnectionFacade]
VAS[VoiceActivityService]
VPS[VoicePlaybackService]
RSF[RealtimeSessionFacade]
Models[voice-connection.models]
VCF --> RSF
VAS --> VCF
VPS --> VCF
click VCF "application/voice-connection.facade.ts" "Proxy to RealtimeSessionFacade"
click VAS "application/voice-activity.service.ts" "RMS-based speaking detection"
click VPS "application/voice-playback.service.ts" "Per-peer GainNode volume chain"
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root"
click Models "domain/voice-connection.models.ts" "Re-exported types"
```
## Voice connection facade
`VoiceConnectionFacade` exposes signals and methods from `RealtimeSessionFacade` without leaking infrastructure details into feature components. It covers:
- Connection state: `isVoiceConnected`, `isMuted`, `isDeafened`, `hasConnectionError`
- Stream access: `getRemoteVoiceStream`, `getLocalStream`, `getRawMicStream`
- Controls: `enableVoice`, `disableVoice`, `toggleMute`, `toggleDeafen`, `toggleNoiseReduction`
- Audio tuning: `setOutputVolume`, `setInputVolume`, `setAudioBitrate`, `setLatencyProfile`
- Peer events: `onRemoteStream`, `onPeerConnected`, `onPeerDisconnected`
- Heartbeat: `startVoiceHeartbeat`, `stopVoiceHeartbeat`
## Speaking detection
`VoiceActivityService` monitors audio levels for local and remote streams using the Web Audio API. Each tracked stream gets its own `AudioContext` with an `AnalyserNode`. A single `requestAnimationFrame` loop polls all analysers.
```mermaid
graph LR
Stream[MediaStream] --> Ctx[AudioContext]
Ctx --> Src[MediaStreamAudioSourceNode]
Src --> Analyser[AnalyserNode<br/>fftSize = 256]
Analyser --> Poll[rAF poll loop]
Poll --> RMS{RMS >= 0.015?}
RMS -- yes --> Speaking[speakingSignal = true]
RMS -- no, 8 frames --> Silent[speakingSignal = false]
click Stream "application/voice-activity.service.ts" "VoiceActivityService.trackStream()"
click Poll "application/voice-activity.service.ts" "VoiceActivityService.poll()"
```
| Parameter | Value |
|---|---|
| FFT size | 256 samples |
| Speaking threshold | RMS >= 0.015 |
| Silent grace period | 8 consecutive frames below threshold |
The service exposes `isSpeaking(userId)` and `volume(userId)` as Angular signals. It automatically tracks remote peers via the `onRemoteStream` and `onPeerDisconnected` observables. Local mic tracking is started explicitly by calling `trackLocalMic(userId, stream)`.
A reactive `speakingMap` signal (a `Map<string, boolean>`) is published whenever any user's speaking state changes, so components can bind directly.
## Voice playback
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline:
```mermaid
graph LR
Remote[Remote stream] --> Src[MediaStreamAudioSourceNode]
Src --> Gain[GainNode<br/>0 - 200%]
Gain --> Dest[MediaStreamAudioDestinationNode]
Dest --> Audio[HTMLAudioElement<br/>.play]
click Remote "application/voice-playback.service.ts" "VoicePlaybackService.setupPeer()"
click Gain "application/voice-playback.service.ts" "VoicePlaybackService.setUserVolume()"
```
Volume per peer is stored in localStorage and restored on reconnect. The range is 0% to 200% (gain values 0.0 to 2.0). When the user deafens, all gain nodes are set to zero; undeafening restores the previous values.
A Chrome workaround attaches a muted `<audio>` element to keep the `AudioContext` from suspending when no audible output is detected.

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ChatEvent } from '../../../core/models/index'; import { ChatEvent } from '../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { LatencyProfile } from '../domain/voice-connection.models'; import { LatencyProfile } from '../domain/voice-connection.models';

View File

@@ -3,9 +3,9 @@ import {
effect, effect,
inject inject
} from '@angular/core'; } from '@angular/core';
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants'; import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
import { ScreenShareFacade } from '../../../../domains/screen-share'; import { ScreenShareFacade } from '../../../domains/screen-share';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceConnectionFacade } from './voice-connection.facade';
export interface PlaybackOptions { export interface PlaybackOptions {
isConnected: boolean; isConnected: boolean;

View File

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

View File

@@ -1,3 +1,4 @@
export * from './application/voice-connection.facade'; export * from './application/voice-connection.facade';
export * from './application/voice-activity.service'; export * from './application/voice-activity.service';
export * from './application/voice-playback.service';
export * from './domain/voice-connection.models'; export * from './domain/voice-connection.models';

View File

@@ -0,0 +1,111 @@
# Voice Session Domain
Tracks voice session metadata across client-side navigation and manages the voice workspace UI state (expanded, minimized, hidden). This domain does not touch WebRTC directly; actual connections live in `voice-connection` and `infrastructure/realtime`.
## Module map
```
voice-session/
├── application/
│ ├── voice-session.facade.ts Tracks active voice session, drives floating controls
│ └── voice-workspace.service.ts Workspace mode (hidden/expanded/minimized), focused stream, mini-window position
├── domain/
│ ├── voice-session.logic.ts isViewingVoiceSessionServer, buildVoiceSessionRoom
│ └── voice-session.models.ts VoiceSessionInfo interface
├── infrastructure/
│ └── voice-settings.storage.ts Persists audio device IDs, volumes, bitrate, latency, noise reduction to localStorage
├── feature/
│ ├── voice-controls/ Full voice control panel (mic, deafen, devices, screen share, settings)
│ └── floating-voice-controls/ Minimal overlay when user navigates away from the voice server
└── index.ts Barrel exports
```
## How the pieces connect
The facade manages session bookkeeping. The workspace service owns view state. Settings storage provides persistence for user preferences. Neither service opens any WebRTC connections.
```mermaid
graph TD
VSF[VoiceSessionFacade]
VWS[VoiceWorkspaceService]
VSS[voiceSettingsStorage]
Logic[voice-session.logic]
VC[VoiceControlsComponent]
FC[FloatingVoiceControlsComponent]
Store[NgRx Store]
VC --> VSF
VC --> VWS
VC --> VSS
FC --> VSF
FC --> VWS
VSF --> Logic
VSF --> Store
VWS --> VSF
click VSF "application/voice-session.facade.ts" "Tracks active voice session"
click VWS "application/voice-workspace.service.ts" "Workspace mode and focused stream"
click VSS "infrastructure/voice-settings.storage.ts" "localStorage persistence for audio settings"
click Logic "domain/voice-session.logic.ts" "Pure helper functions"
click VC "feature/voice-controls/" "Full voice control panel"
click FC "feature/floating-voice-controls/" "Minimal floating overlay"
```
## Session lifecycle
```mermaid
stateDiagram-v2
[*] --> NoSession
NoSession --> Active: startSession(info)
Active --> Active: checkCurrentRoute(serverId)
Active --> NoSession: endSession()
state Active {
[*] --> ViewingServer
ViewingServer --> AwayFromServer: navigated to different server
AwayFromServer --> ViewingServer: navigated back / navigateToVoiceServer()
}
```
When a voice session is active and the user navigates away from the voice-connected server, `showFloatingControls` becomes `true` and the floating overlay appears. Clicking the overlay dispatches `RoomsActions.viewServer` to navigate back.
## Workspace modes
`VoiceWorkspaceService` controls the voice workspace panel state. The workspace is only visible when the user is viewing the voice-connected server.
```mermaid
stateDiagram-v2
[*] --> Hidden
Hidden --> Expanded: open()
Expanded --> Minimized: minimize()
Expanded --> Hidden: close() / showChat()
Minimized --> Expanded: restore()
Minimized --> Hidden: close()
Expanded --> Hidden: voice session ends
Minimized --> Hidden: voice session ends
```
The minimized mode renders a draggable mini-window. Its position is tracked in `miniWindowPosition` and clamped to viewport bounds on resize. `focusedStreamId` controls which screen-share stream gets the widescreen treatment in expanded mode.
## Voice settings
Settings are stored in localStorage under a single JSON key. All values are validated and clamped on load to defend against corrupt storage.
| Setting | Default | Range |
|---|---|---|
| inputDevice | `""` | device ID string |
| outputDevice | `""` | device ID string |
| inputVolume | 100 | 0 -- 100 |
| outputVolume | 100 | 0 -- 100 |
| audioBitrate | 96 kbps | 32 -- 256 |
| latencyProfile | `"balanced"` | low / balanced / high |
| noiseReduction | `true` | boolean |
| screenShareQuality | `"balanced"` | low / balanced / high |
| askScreenShareQuality | `true` | boolean |
| includeSystemAudio | `false` | boolean |
`loadVoiceSettingsFromStorage()` and `saveVoiceSettingsToStorage(patch)` are the only entry points. The save function merges the patch with the current stored value so callers only need to pass changed fields.

View File

@@ -1,4 +1,4 @@
import type { Room } from '../../../core/models'; import type { Room } from '../../../shared-kernel';
import type { VoiceSessionInfo } from './voice-session.models'; import type { VoiceSessionInfo } from './voice-session.models';
export function isViewingVoiceSessionServer( export function isViewingVoiceSessionServer(

View File

@@ -19,14 +19,14 @@ import {
lucideArrowLeft lucideArrowLeft
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { VoiceSessionFacade } from '../../../domains/voice-session'; import { VoiceSessionFacade } from '../../application/voice-session.facade';
import { VoiceConnectionFacade } from '../../../domains/voice-connection'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share'; import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session'; import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
import { UsersActions } from '../../../store/users/users.actions'; import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { UsersActions } from '../../../../store/users/users.actions';
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service'; import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
@Component({ @Component({
selector: 'app-floating-voice-controls', selector: 'app-floating-voice-controls',

View File

@@ -22,20 +22,20 @@ import {
lucideHeadphones lucideHeadphones
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { VoiceSessionFacade } from '../../../domains/voice-session'; import { VoiceSessionFacade } from '../../application/voice-session.facade';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share'; import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { UsersActions } from '../../../store/users/users.actions'; import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { UsersActions } from '../../../../store/users/users.actions';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session'; import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { import {
DebugConsoleComponent, DebugConsoleComponent,
ScreenShareQualityDialogComponent, ScreenShareQualityDialogComponent,
UserAvatarComponent UserAvatarComponent
} from '../../../shared'; } from '../../../../shared';
import { PlaybackOptions, VoicePlaybackService } from './services/voice-playback.service';
interface AudioDevice { interface AudioDevice {
deviceId: string; deviceId: string;

View File

@@ -2,3 +2,7 @@ export * from './application/voice-session.facade';
export * from './application/voice-workspace.service'; export * from './application/voice-workspace.service';
export * from './domain/voice-session.models'; export * from './domain/voice-session.models';
export * from './infrastructure/voice-settings.storage'; export * from './infrastructure/voice-settings.storage';
// Feature components
export { VoiceControlsComponent } from './feature/voice-controls/voice-controls.component';
export { FloatingVoiceControlsComponent } from './feature/floating-voice-controls/floating-voice-controls.component';

View File

@@ -1,18 +1,12 @@
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants'; import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
import { LatencyProfile } from '../../voice-connection/domain/voice-connection.models'; import {
import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from '../../screen-share/domain/screen-share.config'; DEFAULT_LATENCY_PROFILE,
DEFAULT_SCREEN_SHARE_QUALITY,
const LATENCY_PROFILES: LatencyProfile[] = [ LATENCY_PROFILES,
'low', SCREEN_SHARE_QUALITIES,
'balanced', type LatencyProfile,
'high' type ScreenShareQuality
]; } from '../../../shared-kernel';
const SCREEN_SHARE_QUALITIES: ScreenShareQuality[] = [
'performance',
'balanced',
'high-fps',
'quality'
];
export interface VoiceSettings { export interface VoiceSettings {
inputDevice: string; inputDevice: string;
@@ -33,7 +27,7 @@ export const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
inputVolume: 100, inputVolume: 100,
outputVolume: 100, outputVolume: 100,
audioBitrate: 96, audioBitrate: 96,
latencyProfile: 'balanced', latencyProfile: DEFAULT_LATENCY_PROFILE,
includeSystemAudio: false, includeSystemAudio: false,
noiseReduction: true, noiseReduction: true,
screenShareQuality: DEFAULT_SCREEN_SHARE_QUALITY, screenShareQuality: DEFAULT_SCREEN_SHARE_QUALITY,
@@ -71,7 +65,7 @@ function normaliseVoiceSettings(raw: Partial<VoiceSettings>): VoiceSettings {
inputDevice: typeof raw.inputDevice === 'string' ? raw.inputDevice : DEFAULT_VOICE_SETTINGS.inputDevice, inputDevice: typeof raw.inputDevice === 'string' ? raw.inputDevice : DEFAULT_VOICE_SETTINGS.inputDevice,
outputDevice: typeof raw.outputDevice === 'string' ? raw.outputDevice : DEFAULT_VOICE_SETTINGS.outputDevice, outputDevice: typeof raw.outputDevice === 'string' ? raw.outputDevice : DEFAULT_VOICE_SETTINGS.outputDevice,
inputVolume: clampNumber(raw.inputVolume, 0, 100, DEFAULT_VOICE_SETTINGS.inputVolume), inputVolume: clampNumber(raw.inputVolume, 0, 100, DEFAULT_VOICE_SETTINGS.inputVolume),
outputVolume: clampNumber(raw.outputVolume, 0, 100, DEFAULT_VOICE_SETTINGS.outputVolume), outputVolume: clampNumber(raw.outputVolume, 0, 200, DEFAULT_VOICE_SETTINGS.outputVolume),
audioBitrate: clampNumber(raw.audioBitrate, 32, 256, DEFAULT_VOICE_SETTINGS.audioBitrate), audioBitrate: clampNumber(raw.audioBitrate, 32, 256, DEFAULT_VOICE_SETTINGS.audioBitrate),
latencyProfile: LATENCY_PROFILES.includes(raw.latencyProfile as LatencyProfile) latencyProfile: LATENCY_PROFILES.includes(raw.latencyProfile as LatencyProfile)
? raw.latencyProfile as LatencyProfile ? raw.latencyProfile as LatencyProfile

View File

@@ -29,7 +29,7 @@ import {
selectCurrentUser, selectCurrentUser,
selectOnlineUsers selectOnlineUsers
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { BanEntry, User } from '../../../core/models/index'; import { BanEntry, User } from '../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';

View File

@@ -18,8 +18,8 @@ import {
lucideChevronLeft lucideChevronLeft
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component'; import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
import { ScreenShareWorkspaceComponent } from '../../voice/screen-share-workspace/screen-share-workspace.component'; import { ScreenShareWorkspaceComponent } from '../../../domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component';
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component'; import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import { import {

View File

@@ -38,8 +38,8 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareFacade } from '../../../domains/screen-share'; import { ScreenShareFacade } from '../../../domains/screen-share';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection'; import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session'; import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service'; import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { import {
ContextMenuComponent, ContextMenuComponent,
UserAvatarComponent, UserAvatarComponent,
@@ -52,7 +52,7 @@ import {
RoomMember, RoomMember,
Room, Room,
User User
} from '../../../core/models/index'; } from '../../../shared-kernel';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users'; type TabView = 'channels' | 'users';

View File

@@ -14,7 +14,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide'; import { lucidePlus } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { Room, User } from '../../core/models/index'; import { Room, User } from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { VoiceSessionFacade } from '../../domains/voice-session'; import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';

View File

@@ -13,7 +13,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide'; import { lucideX } from '@ng-icons/lucide';
import { Room, BanEntry } from '../../../../core/models/index'; import { Room, BanEntry } from '../../../../shared-kernel';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';

View File

@@ -15,7 +15,7 @@ import {
Room, Room,
RoomMember, RoomMember,
UserRole UserRole
} from '../../../../core/models/index'; } from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';

View File

@@ -11,7 +11,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { lucideCheck } from '@ng-icons/lucide'; import { lucideCheck } from '@ng-icons/lucide';
import { Room } from '../../../../core/models/index'; import { Room } from '../../../../shared-kernel';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@Component({ @Component({

View File

@@ -17,7 +17,7 @@ import {
lucideUnlock lucideUnlock
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { Room } from '../../../../core/models/index'; import { Room } from '../../../../shared-kernel';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ConfirmDialogComponent } from '../../../../shared'; import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../core/services/settings-modal.service';

View File

@@ -28,7 +28,7 @@ import { SettingsModalService, SettingsPage } from '../../../core/services/setti
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room, UserRole } from '../../../core/models/index'; import { Room, UserRole } from '../../../shared-kernel';
import { findRoomMember } from '../../../store/rooms/room-members.helpers'; import { findRoomMember } from '../../../store/rooms/room-members.helpers';
import { GeneralSettingsComponent } from './general-settings/general-settings.component'; import { GeneralSettingsComponent } from './general-settings/general-settings.component';

View File

@@ -93,7 +93,7 @@
[value]="outputVolume()" [value]="outputVolume()"
(input)="onOutputVolumeChange($event)" (input)="onOutputVolumeChange($event)"
min="0" min="0"
max="100" max="200"
id="output-volume-slider" id="output-volume-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />

View File

@@ -21,7 +21,7 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share'; import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session';
import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service'; import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/platform'; import { PlatformService } from '../../../../core/platform';

View File

@@ -32,7 +32,7 @@ import { ServerDirectoryFacade } from '../../domains/server-directory';
import { PlatformService } from '../../core/platform'; import { PlatformService } from '../../core/platform';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared'; import { LeaveServerDialogComponent } from '../../shared';
import { Room } from '../../core/models/index'; import { Room } from '../../shared-kernel';
@Component({ @Component({
selector: 'app-title-bar', selector: 'app-title-bar',

View File

@@ -0,0 +1,113 @@
# Persistence Infrastructure
Offline-first storage layer that keeps messages, users, rooms, reactions, bans, and attachments on the client. The rest of the app only ever talks to `DatabaseService`, which picks the right backend for the current platform at runtime.
## Files
```
persistence/
├── index.ts Barrel (exports DatabaseService)
├── database.service.ts Platform-agnostic facade
├── browser-database.service.ts IndexedDB backend (web)
└── electron-database.service.ts IPC/SQLite backend (desktop)
```
## Platform routing
```mermaid
graph TD
Consumer[Store effects / facades / components]
Consumer --> Facade[DatabaseService<br/>facade]
Facade -->|isBrowser?| Decision{Platform}
Decision -- Browser --> IDB[BrowserDatabaseService<br/>IndexedDB]
Decision -- Electron --> IPC[ElectronDatabaseService<br/>IPC to main process]
IPC --> Main[Electron main process<br/>TypeORM + SQLite]
click Facade "database.service.ts" "DatabaseService - platform-agnostic facade"
click IDB "browser-database.service.ts" "IndexedDB backend for web"
click IPC "electron-database.service.ts" "IPC client for Electron"
```
`DatabaseService` is an `@Injectable({ providedIn: 'root' })` that injects both backends and delegates every call to whichever one matches the current platform. Consumers never import a backend directly.
## Object stores / tables
Both backends store the same entity types:
| Store | Key | Indexes | Description |
|---|---|---|---|
| `messages` | `id` | `roomId` | Chat messages, sorted by timestamp |
| `users` | `oderId` | | User profiles |
| `rooms` | `id` | | Server/room metadata |
| `reactions` | `oderId-emoji-messageId` | | Emoji reactions, deduplicated per user |
| `bans` | `oderId` | | Active bans per room |
| `attachments` | `id` | | File/image metadata tied to messages |
| `meta` | `key` | | Key-value pairs (e.g. `currentUserId`) |
The IndexedDB schema is at version 2.
## How the two backends differ
### Browser (IndexedDB)
All operations run inside IndexedDB transactions in the renderer thread. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string).
```mermaid
sequenceDiagram
participant Eff as NgRx Effect
participant DB as DatabaseService
participant BDB as BrowserDatabaseService
participant IDB as IndexedDB
Eff->>DB: getMessages(roomId, 50)
DB->>BDB: getMessages(roomId, 50)
BDB->>IDB: tx.objectStore("messages")<br/>.index("roomId").getAll(roomId)
IDB-->>BDB: Message[]
Note over BDB: Sort by timestamp, slice, normalise
BDB-->>DB: Message[]
DB-->>Eff: Message[]
```
### Electron (SQLite via IPC)
The renderer sends structured command/query objects through the Electron preload bridge. The main process handles them with TypeORM against a local SQLite file. No database logic runs in the renderer.
```mermaid
sequenceDiagram
participant Eff as NgRx Effect
participant DB as DatabaseService
participant EDB as ElectronDatabaseService
participant IPC as Preload Bridge
participant Main as Main Process<br/>TypeORM + SQLite
Eff->>DB: saveMessage(msg)
DB->>EDB: saveMessage(msg)
EDB->>IPC: api.command({type: "save-message", payload: {message}})
IPC->>Main: ipcRenderer.invoke
Main-->>IPC: void
IPC-->>EDB: Promise resolves
EDB-->>DB: void
DB-->>Eff: void
```
The Electron backend's `initialize()` is a no-op because the main process creates the database before the renderer window opens.
## API surface
Every method on `DatabaseService` maps 1:1 to both backends:
**Messages**: `saveMessage`, `getMessages`, `getMessageById`, `deleteMessage`, `updateMessage`, `clearRoomMessages`
**Reactions**: `saveReaction`, `removeReaction`, `getReactionsForMessage`
**Users**: `saveUser`, `getUser`, `getCurrentUser`, `setCurrentUserId`, `getUsersByRoom`, `updateUser`
**Rooms**: `saveRoom`, `getRoom`, `getAllRooms`, `deleteRoom`, `updateRoom`
**Bans**: `saveBan`, `removeBan`, `getBansForRoom`, `isUserBanned`
**Attachments**: `saveAttachment`, `getAttachmentsForMessage`, `getAllAttachments`, `deleteAttachmentsForMessage`
**Lifecycle**: `initialize`, `clearAllData`
The facade also exposes an `isReady` signal that flips to `true` after `initialize()` completes, so components can gate rendering until the DB is available.

View File

@@ -2,13 +2,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
DELETED_MESSAGE_CONTENT, DELETED_MESSAGE_CONTENT,
ChatAttachmentMeta,
Message, Message,
User, User,
Room, Room,
Reaction, Reaction,
BanEntry BanEntry
} from '../../core/models/index'; } from '../../shared-kernel';
import type { ChatAttachmentMeta } from '../../shared-kernel';
/** IndexedDB database name for the MetoYou application. */ /** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou'; const DATABASE_NAME = 'metoyou';

View File

@@ -9,9 +9,9 @@ import {
User, User,
Room, Room,
Reaction, Reaction,
BanEntry, BanEntry
ChatAttachmentMeta } from '../../shared-kernel';
} from '../../core/models/index'; import type { ChatAttachmentMeta } from '../../shared-kernel';
import { PlatformService } from '../../core/platform'; import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service'; import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-database.service'; import { ElectronDatabaseService } from './electron-database.service';

View File

@@ -5,7 +5,7 @@ import {
Room, Room,
Reaction, Reaction,
BanEntry BanEntry
} from '../../core/models/index'; } from '../../shared-kernel';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models'; import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';

View File

@@ -0,0 +1,322 @@
# Realtime Infrastructure
Low-level WebRTC and WebSocket plumbing that the rest of the app sits on top of. Nothing in here knows about Angular components, NgRx, or domain logic. It exposes observables, signals, and callbacks that higher layers (facades, effects, components) consume.
## Module map
```
realtime/
├── realtime-session.service.ts Composition root (WebRTCService)
├── realtime.types.ts PeerData, credentials, tracker types
├── realtime.constants.ts ICE servers, signal types, bitrates, intervals
├── signaling/ WebSocket layer
│ ├── signaling.manager.ts One WebSocket per signaling URL
│ ├── signaling-transport-handler.ts Routes messages to the right socket
│ ├── server-signaling-coordinator.ts Maps peers/servers to signaling URLs
│ ├── signaling-message-handler.ts Dispatches incoming signaling messages
│ └── server-membership-signaling-handler.ts Join / leave / switch protocol
├── peer-connection-manager/ WebRTC peer connections
│ ├── peer-connection.manager.ts Owns all RTCPeerConnection instances
│ ├── shared.ts PeerData type + state factory
│ ├── connection/
│ │ ├── create-peer-connection.ts RTCPeerConnection factory (ICE, transceivers)
│ │ └── negotiation.ts Offer/answer/ICE with collision handling
│ ├── messaging/
│ │ ├── data-channel.ts Ordered data channel for chat + control
│ │ └── ping.ts Latency measurement (PING/PONG every 5s)
│ ├── recovery/
│ │ └── peer-recovery.ts Disconnect grace period + reconnect loop
│ └── streams/
│ └── remote-streams.ts Classifies incoming tracks (voice vs screen)
├── media/ Local capture and processing
│ ├── media.manager.ts getUserMedia, mute, deafen, gain pipeline
│ ├── noise-reduction.manager.ts RNNoise AudioWorklet graph
│ ├── voice-session-controller.ts Higher-level wrapper over MediaManager
│ ├── screen-share.manager.ts Screen capture + per-peer track distribution
│ └── screen-share-platforms/
│ ├── shared.ts Electron desktopCapturer types
│ ├── browser-screen-share.capture.ts Standard getDisplayMedia
│ ├── desktop-electron-screen-share.capture.ts Electron source picker (Windows)
│ └── linux-electron-screen-share.capture.ts PulseAudio/PipeWire routing (Linux)
├── streams/ Stream facades
│ ├── peer-media-facade.ts Unified API over peers, media, screen share
│ └── remote-screen-share-request-controller.ts On-demand screen share delivery
├── state/
│ └── webrtc-state-controller.ts Angular Signals for all connection state
└── logging/
├── webrtc-logger.ts Conditional [WebRTC] prefixed logging
└── debug-network-metrics.ts Per-peer stats (drops, latency, throughput)
```
## How it all fits together
`WebRTCService` is the composition root. It instantiates every other manager, then wires their callbacks together after construction (to avoid circular references). No manager imports another manager directly.
```mermaid
graph TD
WS[WebRTCService<br/>composition root]
WS --> SC[SignalingTransportHandler]
WS --> PCM[PeerConnectionManager]
WS --> MM[MediaManager]
WS --> SSM[ScreenShareManager]
WS --> State[WebRtcStateController<br/>Angular Signals]
WS --> VSC[VoiceSessionController]
WS --> PMF[PeerMediaFacade]
WS --> RSSRC[RemoteScreenShareRequestController]
SC --> SM1[SignalingManager<br/>socket A]
SC --> SM2[SignalingManager<br/>socket B]
SC --> Coord[ServerSignalingCoordinator]
PCM --> Conn[create-peer-connection]
PCM --> Neg[negotiation]
PCM --> DC[data-channel]
PCM --> Ping[ping]
PCM --> Rec[peer-recovery]
PCM --> RS[remote-streams]
MM --> NR[NoiseReductionManager<br/>RNNoise worklet]
SSM --> BrowserCap[Browser capture]
SSM --> ElectronCap[Electron capture]
SSM --> LinuxCap[Linux audio routing]
click WS "realtime-session.service.ts" "WebRTCService - composition root"
click SC "signaling/signaling-transport-handler.ts" "Routes messages to the right WebSocket"
click PCM "peer-connection-manager/peer-connection.manager.ts" "Owns all RTCPeerConnection instances"
click MM "media/media.manager.ts" "getUserMedia, mute, deafen, gain pipeline"
click SSM "media/screen-share.manager.ts" "Screen capture and per-peer distribution"
click State "state/webrtc-state-controller.ts" "Angular Signals for connection state"
click VSC "media/voice-session-controller.ts" "Higher-level voice session wrapper"
click PMF "streams/peer-media-facade.ts" "Unified API over peers, media, screen share"
click RSSRC "streams/remote-screen-share-request-controller.ts" "On-demand screen share delivery"
click SM1 "signaling/signaling.manager.ts" "One WebSocket per signaling URL"
click SM2 "signaling/signaling.manager.ts" "One WebSocket per signaling URL"
click Coord "signaling/server-signaling-coordinator.ts" "Maps peers/servers to signaling URLs"
click Conn "peer-connection-manager/connection/create-peer-connection.ts" "RTCPeerConnection factory"
click Neg "peer-connection-manager/connection/negotiation.ts" "Offer/answer/ICE with collision handling"
click DC "peer-connection-manager/messaging/data-channel.ts" "Ordered data channel for chat + control"
click Ping "peer-connection-manager/messaging/ping.ts" "Latency measurement via PING/PONG"
click Rec "peer-connection-manager/recovery/peer-recovery.ts" "Disconnect grace period + reconnect loop"
click RS "peer-connection-manager/streams/remote-streams.ts" "Classifies incoming tracks"
click NR "media/noise-reduction.manager.ts" "RNNoise AudioWorklet graph"
click BrowserCap "media/screen-share-platforms/browser-screen-share.capture.ts" "Standard getDisplayMedia"
click ElectronCap "media/screen-share-platforms/desktop-electron-screen-share.capture.ts" "Electron source picker"
click LinuxCap "media/screen-share-platforms/linux-electron-screen-share.capture.ts" "PulseAudio/PipeWire routing"
```
## Signaling (WebSocket)
The signaling layer's only job is getting two peers to exchange SDP offers/answers and ICE candidates so they can establish a direct WebRTC connection. Once the peer connection is up, signaling is only used for presence (user joined/left) and reconnection.
Each signaling URL gets its own `SignalingManager` (one WebSocket each). `SignalingTransportHandler` picks the right socket based on which server the message is for. `ServerSignalingCoordinator` tracks which peers belong to which servers and which signaling URLs, so we know when it is safe to tear down a peer connection after leaving a server.
```mermaid
sequenceDiagram
participant UI as App
participant STH as SignalingTransportHandler
participant SM as SignalingManager
participant WS as WebSocket
participant Srv as Signaling Server
UI->>STH: identify(credentials)
STH->>SM: send(identify message)
SM->>WS: ws.send(JSON)
WS->>Srv: identify
UI->>STH: joinServer(serverId)
STH->>SM: send(join_server)
SM->>WS: ws.send(JSON)
Srv-->>WS: server_users [peerA, peerB]
WS-->>SM: onmessage
SM-->>STH: messageReceived$
STH-->>UI: routes to SignalingMessageHandler
```
### Reconnection
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything.
## Peer connection lifecycle
Peers connect to each other directly with `RTCPeerConnection`. The "initiator" (whoever was already in the room) creates the data channel and audio/video transceivers, then sends an offer. The other side creates an answer.
```mermaid
sequenceDiagram
participant A as Peer A (initiator)
participant Sig as Signaling Server
participant B as Peer B
Note over A: createPeerConnection(B, initiator=true)
Note over A: Creates data channel + transceivers
A->>Sig: offer (SDP)
Sig->>B: offer (SDP)
Note over B: createPeerConnection(A, initiator=false)
Note over B: setRemoteDescription(offer)
Note over B: Attach local audio tracks
B->>Sig: answer (SDP)
Sig->>A: answer (SDP)
Note over A: setRemoteDescription(answer)
A->>Sig: ICE candidates
Sig->>B: ICE candidates
B->>Sig: ICE candidates
Sig->>A: ICE candidates
Note over A,B: RTCPeerConnection state -> "connected"
Note over A,B: Data channel opens, voice flows
```
### Offer collision
Both peers might send offers at the same time ("glare"). The negotiation module implements the "polite peer" pattern: one side is designated polite (the non-initiator) and will roll back its local offer if it detects a collision, then accept the remote offer instead. The impolite side ignores the incoming offer.
### Disconnect recovery
```mermaid
stateDiagram-v2
[*] --> Connected
Connected --> Disconnected: connectionState = "disconnected"
Disconnected --> Connected: recovers within 10s
Disconnected --> Failed: grace period expires
Failed --> Reconnecting: schedule reconnect (every 5s)
Reconnecting --> Connected: new offer accepted
Reconnecting --> GaveUp: 12 attempts failed
Connected --> Closed: leave / cleanup
GaveUp --> [*]
Closed --> [*]
```
When a peer connection enters `disconnected`, a 10-second grace period starts. If it recovers on its own (network blip), nothing happens. If it reaches `failed`, the connection is torn down and a reconnect loop starts: a fresh `RTCPeerConnection` is created and a new offer is sent every 5 seconds, up to 12 attempts.
## Data channel
A single ordered data channel carries all peer-to-peer messages: chat events, voice/screen state broadcasts, state requests, pings, and screen share control.
Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers.
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
## Media pipeline
### Voice
```mermaid
graph LR
Mic[getUserMedia] --> Raw[Raw mic stream]
Raw --> RNN{RNNoise<br/>enabled?}
RNN -- yes --> Worklet[AudioWorklet<br/>NoiseSuppressor]
RNN -- no --> Gain
Worklet --> Gain{Input gain<br/>adjusted?}
Gain -- yes --> GainNode[GainNode pipeline]
Gain -- no --> Out[Local media stream]
GainNode --> Out
Out --> Peers[replaceTrack on<br/>all peer audio senders]
click Mic "media/media.manager.ts" "MediaManager.enableVoice()"
click Worklet "media/noise-reduction.manager.ts" "NoiseReductionManager.enable()"
click GainNode "media/media.manager.ts" "MediaManager.applyInputGainToCurrentStream()"
click Out "media/media.manager.ts" "MediaManager.localMediaStream"
click Peers "media/media.manager.ts" "MediaManager.bindLocalTracksToAllPeers()"
```
`MediaManager` grabs the mic with `getUserMedia`, optionally pipes it through the RNNoise AudioWorklet for noise reduction (48 kHz, loaded from `rnnoise-worklet.js`), optionally runs it through a `GainNode` for input volume control, and then pushes the resulting stream to every connected peer via `replaceTrack`.
Mute just disables the audio track (`track.enabled = false`), the connection stays up. Deafen suppresses incoming audio playback on the local side.
### Screen share
Screen capture uses a platform-specific strategy:
| Platform | Capture method |
|---|---|
| Browser | `getDisplayMedia` with quality presets |
| Windows (Electron) | Electron `desktopCapturer.getSources()` with a source picker UI |
| Linux (Electron) | `getDisplayMedia` for video + PulseAudio/PipeWire routing for system audio, keeping voice playback out of the capture |
Screen share tracks are distributed on-demand. A peer sends a `SCREEN_SHARE_REQUEST` message over the data channel, and only then does the sharer attach screen tracks to that peer's connection and renegotiate.
```mermaid
sequenceDiagram
participant V as Viewer
participant S as Sharer
V->>S: SCREEN_SHARE_REQUEST (data channel)
Note over S: Add viewer to requestedViewerPeerIds
Note over S: Attach screen video + audio senders
S->>V: renegotiate (new offer with screen tracks)
V->>S: answer
Note over V: ontrack fires with screen video
Note over V: Classified as screen share stream
Note over V: UI renders video
V->>S: SCREEN_SHARE_STOP (data channel)
Note over S: Remove screen senders
S->>V: renegotiate (offer without screen tracks)
```
## State
`WebRtcStateController` holds all connection state as Angular Signals: `isConnected`, `isMuted`, `isDeafened`, `isScreenSharing`, `connectedPeers`, `peerLatencies`, etc. Managers call update methods on the controller after state changes. Components and facades read these signals reactively.
## Logging
`WebRTCLogger` wraps `console.*` with a `[WebRTC]` prefix and a debug flag so logging can be toggled at runtime. `DebugNetworkMetrics` tracks per-peer stats (connection drops, handshake counts, message counts, download rates) for the debug console UI.
## ICE and STUN
WebRTC connections require a way for two peers to discover how to reach each other across different networks (NATs, firewalls, etc.). This is handled by ICE, with help from STUN.
### ICE (Interactive Connectivity Establishment)
ICE is the mechanism WebRTC uses to establish a connection between peers. Instead of relying on a single network path, it:
- Gathers multiple possible connection candidates (IP address + port pairs)
- Exchanges those candidates via the signaling layer
- Attempts connectivity checks between all candidate pairs
- Selects the first working path
Typical candidate types include:
- **Host candidates** - local network interfaces (e.g. LAN IPs)
- **Server reflexive candidates** - public-facing address discovered via STUN
- **Relay candidates** - provided by TURN servers (fallback)
ICE runs automatically as part of `RTCPeerConnection`. As candidates are discovered, they are emitted via `onicecandidate` and must be forwarded to the remote peer through signaling.
Connection state transitions (e.g. `checking``connected``failed`) reflect ICE progress.
### STUN (Session Traversal Utilities for NAT)
STUN is used to determine a peer's public-facing IP address and port when behind a NAT.
A STUN server responds with the external address it observes for a request. This allows a peer to generate a **server reflexive candidate**, which can be used by other peers to attempt a direct connection.
Without STUN, only local (host) candidates would be available, which typically do not work across different networks.
### TURN
TURN (Traversal Using Relays around NAT) is a fallback mechanism used in some WebRTC systems when direct peer-to-peer connectivity cannot be established.
Instead of connecting peers directly:
- Each peer establishes a connection to a TURN server
- The TURN server relays all media and data between peers
This approach is more reliable in restrictive network environments but introduces additional latency and bandwidth overhead, since all traffic flows through the relay instead of directly between peers.
Toju/Zoracord does not use TURN and does not have code written to support it.
### Summary
- **ICE** coordinates connection establishment by trying multiple network paths
- **STUN** provides public-facing address discovery for NAT traversal

View File

@@ -5,7 +5,7 @@
* and optional RNNoise-based noise reduction. * and optional RNNoise-based noise reduction.
*/ */
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ChatEvent } from '../../../core/models'; import { ChatEvent } from '../../../shared-kernel';
import { LatencyProfile } from '../realtime.constants'; import { LatencyProfile } from '../realtime.constants';
import { PeerData } from '../realtime.types'; import { PeerData } from '../realtime.types';
import { WebRTCLogger } from '../logging/webrtc-logger'; import { WebRTCLogger } from '../logging/webrtc-logger';
@@ -349,10 +349,10 @@ export class MediaManager {
/** /**
* Set the output volume for remote audio. * Set the output volume for remote audio.
* *
* @param volume - A value between {@link VOLUME_MIN} (0) and {@link VOLUME_MAX} (1). * @param volume - Normalised value: 0 = silent, 1 = 100%, up to 2 = 200%.
*/ */
setOutputVolume(volume: number): void { setOutputVolume(volume: number): void {
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume)); this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(2, volume));
} }
/** /**

View File

@@ -1,4 +1,4 @@
import { ChatEvent } from '../../../../core/models'; import { ChatEvent } from '../../../../shared-kernel';
import { import {
DATA_CHANNEL_HIGH_WATER_BYTES, DATA_CHANNEL_HIGH_WATER_BYTES,
DATA_CHANNEL_LOW_WATER_BYTES, DATA_CHANNEL_LOW_WATER_BYTES,

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { ChatEvent } from '../../../core/models'; import { ChatEvent } from '../../../shared-kernel';
import { recordDebugNetworkDownloadRates } from '../logging/debug-network-metrics'; import { recordDebugNetworkDownloadRates } from '../logging/debug-network-metrics';
import { WebRTCLogger } from '../logging/webrtc-logger'; import { WebRTCLogger } from '../logging/webrtc-logger';
import { PeerData } from '../realtime.types'; import { PeerData } from '../realtime.types';

View File

@@ -1,5 +1,5 @@
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ChatEvent } from '../../../core/models'; import { ChatEvent } from '../../../shared-kernel';
import { WebRTCLogger } from '../logging/webrtc-logger'; import { WebRTCLogger } from '../logging/webrtc-logger';
import { import {
DisconnectedPeerEntry, DisconnectedPeerEntry,

View File

@@ -18,7 +18,8 @@ import {
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { SignalingMessage, ChatEvent } from '../../core/models/index'; import { ChatEvent } from '../../shared-kernel';
import type { SignalingMessage } from '../../shared-kernel';
import { TimeSyncService } from '../../core/services/time-sync.service'; import { TimeSyncService } from '../../core/services/time-sync.service';
import { DebuggingService } from '../../core/services/debugging'; import { DebuggingService } from '../../core/services/debugging';
import { ScreenShareSourcePickerService } from '../../domains/screen-share'; import { ScreenShareSourcePickerService } from '../../domains/screen-share';

View File

@@ -1,3 +1,5 @@
import type { LatencyProfile } from '../../shared-kernel';
/** /**
* All magic numbers and strings used across the WebRTC subsystem. * All magic numbers and strings used across the WebRTC subsystem.
* Centralised here so nothing is hard-coded inline. * Centralised here so nothing is hard-coded inline.
@@ -41,7 +43,7 @@ export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
export const SCREEN_SHARE_IDEAL_HEIGHT = 1080; export const SCREEN_SHARE_IDEAL_HEIGHT = 1080;
export const SCREEN_SHARE_IDEAL_FRAME_RATE = 30; export const SCREEN_SHARE_IDEAL_FRAME_RATE = 30;
/** Electron source name to prefer for whole-screen capture */ /** Electron source name to prefer for whole-screen capture */
export { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../domains/screen-share/domain/screen-share.config'; export { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../shared-kernel';
/** Minimum audio bitrate (bps) */ /** Minimum audio bitrate (bps) */
export const AUDIO_BITRATE_MIN_BPS = 16_000; export const AUDIO_BITRATE_MIN_BPS = 16_000;
@@ -50,12 +52,13 @@ export const AUDIO_BITRATE_MAX_BPS = 256_000;
/** Multiplier to convert kbps → bps */ /** Multiplier to convert kbps → bps */
export const KBPS_TO_BPS = 1_000; export const KBPS_TO_BPS = 1_000;
/** Pre-defined latency-to-bitrate mappings (bps) */ /** Pre-defined latency-to-bitrate mappings (bps) */
export const LATENCY_PROFILE_BITRATES = { export const LATENCY_PROFILE_BITRATES: Record<LatencyProfile, number> = {
low: 64_000, low: 64_000,
balanced: 96_000, balanced: 96_000,
high: 128_000 high: 128_000
} as const; };
export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES;
export type { LatencyProfile } from '../../shared-kernel';
export const TRANSCEIVER_SEND_RECV: RTCRtpTransceiverDirection = 'sendrecv'; export const TRANSCEIVER_SEND_RECV: RTCRtpTransceiverDirection = 'sendrecv';
export const TRANSCEIVER_RECV_ONLY: RTCRtpTransceiverDirection = 'recvonly'; export const TRANSCEIVER_RECV_ONLY: RTCRtpTransceiverDirection = 'recvonly';

View File

@@ -1 +1,10 @@
export * from '../../domains/screen-share/domain/screen-share.config'; export {
DEFAULT_SCREEN_SHARE_QUALITY,
DEFAULT_SCREEN_SHARE_START_OPTIONS,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
SCREEN_SHARE_QUALITY_PRESETS,
type ScreenShareQuality,
type ScreenShareQualityPreset,
type ScreenShareStartOptions
} from '../../shared-kernel';

View File

@@ -1,4 +1,4 @@
import { SignalingMessage } from '../../../core/models'; import type { SignalingMessage } from '../../../shared-kernel';
import { PeerData } from '../realtime.types'; import { PeerData } from '../realtime.types';
import { import {
SIGNALING_TYPE_ANSWER, SIGNALING_TYPE_ANSWER,

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