ddd test 2
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,7 +6,9 @@
|
|||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
|
*.sqlite
|
||||||
|
*/architecture.md
|
||||||
|
/docs
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
62
src/app/domains/README.md
Normal 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/` |
|
||||||
148
src/app/domains/attachment/README.md
Normal file
148
src/app/domains/attachment/README.md
Normal 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.
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
74
src/app/domains/auth/README.md
Normal file
74
src/app/domains/auth/README.md
Normal 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.
|
||||||
@@ -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',
|
||||||
@@ -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',
|
||||||
@@ -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',
|
||||||
143
src/app/domains/chat/README.md
Normal file
143
src/app/domains/chat/README.md
Normal 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".
|
||||||
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal file
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal 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;
|
||||||
|
}
|
||||||
31
src/app/domains/chat/domain/message.rules.ts
Normal file
31
src/app/domains/chat/domain/message.rules.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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({
|
||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
@@ -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,
|
||||||
@@ -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',
|
||||||
@@ -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';
|
||||||
|
|||||||
137
src/app/domains/screen-share/README.md
Normal file
137
src/app/domains/screen-share/README.md
Normal 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()
|
||||||
|
```
|
||||||
@@ -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
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { User } from '../../../core/models';
|
import { User } from '../../../../shared-kernel';
|
||||||
|
|
||||||
export interface ScreenShareWorkspaceStreamItem {
|
export interface ScreenShareWorkspaceStreamItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -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';
|
||||||
|
|||||||
176
src/app/domains/server-directory/README.md
Normal file
176
src/app/domains/server-directory/README.md
Normal 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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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',
|
||||||
@@ -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,
|
||||||
|
|||||||
97
src/app/domains/voice-connection/README.md
Normal file
97
src/app/domains/voice-connection/README.md
Normal 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.
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
111
src/app/domains/voice-session/README.md
Normal file
111
src/app/domains/voice-session/README.md
Normal 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.
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
113
src/app/infrastructure/persistence/README.md
Normal file
113
src/app/infrastructure/persistence/README.md
Normal 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.
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
322
src/app/infrastructure/realtime/README.md
Normal file
322
src/app/infrastructure/realtime/README.md
Normal 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
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user