From 971a5afb8b810781182f0382fd18b86a60af779b Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 23 Mar 2026 00:23:56 +0100 Subject: [PATCH] ddd test 2 --- .gitignore | 4 +- README.md | 13 +- docs/architecture.md | 139 ------- src/app/app.routes.ts | 8 +- src/app/app.ts | 2 +- src/app/core/models/index.ts | 356 +++-------------- src/app/core/realtime/index.ts | 5 + src/app/domains/README.md | 62 +++ src/app/domains/attachment/README.md | 148 +++++++ .../domain/attachment-transfer.models.ts | 3 +- .../attachment/domain/attachment.models.ts | 2 +- src/app/domains/auth/README.md | 74 ++++ .../auth/feature}/login/login.component.html | 0 .../auth/feature}/login/login.component.ts | 10 +- .../feature}/register/register.component.html | 0 .../feature}/register/register.component.ts | 10 +- .../feature}/user-bar/user-bar.component.html | 0 .../feature}/user-bar/user-bar.component.ts | 2 +- src/app/domains/chat/README.md | 143 +++++++ .../domains/chat/domain/message-sync.rules.ts | 59 +++ src/app/domains/chat/domain/message.rules.ts | 31 ++ .../chat-messages.component.html | 0 .../chat-messages.component.scss | 0 .../chat-messages/chat-messages.component.ts | 18 +- .../chat-message-composer.component.html | 0 .../chat-message-composer.component.scss | 0 .../chat-message-composer.component.ts | 8 +- .../chat-message-item.component.html | 0 .../chat-message-item.component.scss | 0 .../chat-message-item.component.ts | 8 +- .../chat-message-list.component.html | 0 .../chat-message-list.component.ts | 4 +- .../chat-message-overlays.component.html | 0 .../chat-message-overlays.component.ts | 4 +- .../models/chat-messages.models.ts | 4 +- .../services/chat-markdown.service.ts | 0 .../klipy-gif-picker.component.html | 0 .../klipy-gif-picker.component.ts | 2 +- .../typing-indicator.component.html | 0 .../typing-indicator.component.ts | 4 +- .../user-list/user-list.component.html | 0 .../feature}/user-list/user-list.component.ts | 8 +- src/app/domains/chat/index.ts | 6 + src/app/domains/screen-share/README.md | 137 +++++++ .../domain/screen-share.config.ts | 98 +---- .../screen-share-viewer.component.html | 0 .../screen-share-viewer.component.ts | 10 +- .../screen-share-playback.service.ts | 0 .../screen-share-stream-tile.component.html | 0 .../screen-share-stream-tile.component.ts | 2 +- .../screen-share-workspace.component.html | 0 .../screen-share-workspace.component.ts | 23 +- .../screen-share-workspace.models.ts | 2 +- src/app/domains/screen-share/index.ts | 5 + src/app/domains/server-directory/README.md | 176 +++++++++ .../application/server-directory.facade.ts | 3 +- .../domain/server-directory.models.ts | 22 +- .../feature}/invite/invite.component.html | 0 .../feature}/invite/invite.component.ts | 16 +- .../server-search.component.html | 0 .../server-search/server-search.component.ts | 23 +- .../server-directory-api.service.ts | 3 +- src/app/domains/voice-connection/README.md | 97 +++++ .../application/voice-connection.facade.ts | 2 +- .../application}/voice-playback.service.ts | 6 +- .../domain/voice-connection.models.ts | 6 +- src/app/domains/voice-connection/index.ts | 1 + src/app/domains/voice-session/README.md | 111 ++++++ .../domain/voice-session.logic.ts | 2 +- .../floating-voice-controls.component.html | 0 .../floating-voice-controls.component.ts | 16 +- .../voice-controls.component.html | 0 .../voice-controls.component.ts | 20 +- src/app/domains/voice-session/index.ts | 4 + .../infrastructure/voice-settings.storage.ts | 26 +- .../admin-panel/admin-panel.component.ts | 2 +- .../room/chat-room/chat-room.component.ts | 4 +- .../rooms-side-panel.component.ts | 6 +- .../servers/servers-rail.component.ts | 2 +- .../bans-settings/bans-settings.component.ts | 2 +- .../members-settings.component.ts | 2 +- .../permissions-settings.component.ts | 2 +- .../server-settings.component.ts | 2 +- .../settings-modal.component.ts | 2 +- .../voice-settings.component.html | 2 +- .../voice-settings.component.ts | 2 +- src/app/features/shell/title-bar.component.ts | 2 +- src/app/infrastructure/persistence/README.md | 113 ++++++ .../persistence/browser-database.service.ts | 4 +- .../persistence/database.service.ts | 6 +- .../persistence/electron-database.service.ts | 2 +- src/app/infrastructure/realtime/README.md | 322 +++++++++++++++ .../realtime/media/media.manager.ts | 6 +- .../messaging/data-channel.ts | 2 +- .../peer-connection.manager.ts | 2 +- .../peer-connection-manager/shared.ts | 2 +- .../realtime/realtime-session.service.ts | 3 +- .../realtime/realtime.constants.ts | 11 +- .../realtime/screen-share.config.ts | 11 +- .../signaling/signaling-message-handler.ts | 2 +- .../signaling/signaling-transport-handler.ts | 2 +- .../realtime/signaling/signaling.manager.ts | 2 +- .../realtime/streams/peer-media-facade.ts | 2 +- .../remote-screen-share-request-controller.ts | 2 +- src/app/shared-kernel/README.md | 31 ++ src/app/shared-kernel/attachment-contracts.ts | 14 + src/app/shared-kernel/chat-events.ts | 374 ++++++++++++++++++ src/app/shared-kernel/index.ts | 9 + src/app/shared-kernel/media-preferences.ts | 98 +++++ src/app/shared-kernel/message.models.ts | 24 ++ src/app/shared-kernel/moderation.models.ts | 10 + src/app/shared-kernel/room.models.ts | 54 +++ src/app/shared-kernel/signaling-contracts.ts | 20 + src/app/shared-kernel/user.models.ts | 33 ++ src/app/shared-kernel/voice-state.models.ts | 17 + .../leave-server-dialog.component.ts | 2 +- .../user-volume-menu.component.ts | 2 +- .../messages/messages-incoming.handlers.ts | 12 +- src/app/store/messages/messages.actions.ts | 2 +- src/app/store/messages/messages.effects.ts | 7 +- src/app/store/messages/messages.helpers.ts | 112 ++---- src/app/store/messages/messages.reducer.ts | 2 +- .../store/rooms/room-members-sync.effects.ts | 2 +- src/app/store/rooms/room-members.helpers.ts | 2 +- src/app/store/rooms/rooms.actions.ts | 4 +- src/app/store/rooms/rooms.effects.ts | 2 +- src/app/store/rooms/rooms.reducer.ts | 4 +- src/app/store/users/users.actions.ts | 2 +- src/app/store/users/users.effects.ts | 2 +- src/app/store/users/users.reducer.ts | 2 +- 130 files changed, 2493 insertions(+), 822 deletions(-) delete mode 100644 docs/architecture.md create mode 100644 src/app/domains/README.md create mode 100644 src/app/domains/attachment/README.md create mode 100644 src/app/domains/auth/README.md rename src/app/{features/auth => domains/auth/feature}/login/login.component.html (100%) rename src/app/{features/auth => domains/auth/feature}/login/login.component.ts (89%) rename src/app/{features/auth => domains/auth/feature}/register/register.component.html (100%) rename src/app/{features/auth => domains/auth/feature}/register/register.component.ts (89%) rename src/app/{features/auth => domains/auth/feature}/user-bar/user-bar.component.html (100%) rename src/app/{features/auth => domains/auth/feature}/user-bar/user-bar.component.ts (92%) create mode 100644 src/app/domains/chat/README.md create mode 100644 src/app/domains/chat/domain/message-sync.rules.ts create mode 100644 src/app/domains/chat/domain/message.rules.ts rename src/app/{features/chat => domains/chat/feature}/chat-messages/chat-messages.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/chat-messages.component.scss (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/chat-messages.component.ts (95%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-composer/chat-message-composer.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-composer/chat-message-composer.component.scss (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-composer/chat-message-composer.component.ts (97%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-item/chat-message-item.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-item/chat-message-item.component.scss (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-item/chat-message-item.component.ts (98%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-list/chat-message-list.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-list/chat-message-list.component.ts (98%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-overlays/chat-message-overlays.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/components/message-overlays/chat-message-overlays.component.ts (94%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/models/chat-messages.models.ts (83%) rename src/app/{features/chat => domains/chat/feature}/chat-messages/services/chat-markdown.service.ts (100%) rename src/app/{features/chat => domains/chat/feature}/klipy-gif-picker/klipy-gif-picker.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/klipy-gif-picker/klipy-gif-picker.component.ts (98%) rename src/app/{features/chat => domains/chat/feature}/typing-indicator/typing-indicator.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/typing-indicator/typing-indicator.component.ts (95%) rename src/app/{features/chat => domains/chat/feature}/user-list/user-list.component.html (100%) rename src/app/{features/chat => domains/chat/feature}/user-list/user-list.component.ts (95%) create mode 100644 src/app/domains/screen-share/README.md rename src/app/{features/voice => domains/screen-share/feature}/screen-share-viewer/screen-share-viewer.component.html (100%) rename src/app/{features/voice => domains/screen-share/feature}/screen-share-viewer/screen-share-viewer.component.ts (95%) rename src/app/{features/voice => domains/screen-share/feature}/screen-share-workspace/screen-share-playback.service.ts (100%) rename src/app/{features/voice => domains/screen-share/feature}/screen-share-workspace/screen-share-stream-tile.component.html (100%) rename src/app/{features/voice => domains/screen-share/feature}/screen-share-workspace/screen-share-stream-tile.component.ts (99%) rename src/app/{features/voice => domains/screen-share/feature}/screen-share-workspace/screen-share-workspace.component.html (100%) rename src/app/{features/voice => domains/screen-share/feature}/screen-share-workspace/screen-share-workspace.component.ts (97%) rename src/app/{features/voice => domains/screen-share/feature}/screen-share-workspace/screen-share-workspace.models.ts (74%) create mode 100644 src/app/domains/server-directory/README.md rename src/app/{features => domains/server-directory/feature}/invite/invite.component.html (100%) rename src/app/{features => domains/server-directory/feature}/invite/invite.component.ts (89%) rename src/app/{features => domains/server-directory/feature}/server-search/server-search.component.html (100%) rename src/app/{features => domains/server-directory/feature}/server-search/server-search.component.ts (93%) create mode 100644 src/app/domains/voice-connection/README.md rename src/app/{features/voice/voice-controls/services => domains/voice-connection/application}/voice-playback.service.ts (97%) create mode 100644 src/app/domains/voice-session/README.md rename src/app/{features/voice => domains/voice-session/feature}/floating-voice-controls/floating-voice-controls.component.html (100%) rename src/app/{features/voice => domains/voice-session/feature}/floating-voice-controls/floating-voice-controls.component.ts (94%) rename src/app/{features/voice => domains/voice-session/feature}/voice-controls/voice-controls.component.html (100%) rename src/app/{features/voice => domains/voice-session/feature}/voice-controls/voice-controls.component.ts (96%) create mode 100644 src/app/infrastructure/persistence/README.md create mode 100644 src/app/infrastructure/realtime/README.md create mode 100644 src/app/shared-kernel/README.md create mode 100644 src/app/shared-kernel/attachment-contracts.ts create mode 100644 src/app/shared-kernel/chat-events.ts create mode 100644 src/app/shared-kernel/index.ts create mode 100644 src/app/shared-kernel/media-preferences.ts create mode 100644 src/app/shared-kernel/message.models.ts create mode 100644 src/app/shared-kernel/moderation.models.ts create mode 100644 src/app/shared-kernel/room.models.ts create mode 100644 src/app/shared-kernel/signaling-contracts.ts create mode 100644 src/app/shared-kernel/user.models.ts create mode 100644 src/app/shared-kernel/voice-state.models.ts diff --git a/.gitignore b/.gitignore index e0870eb..3a0ad44 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,9 @@ /tmp /out-tsc /bazel-out - +*.sqlite +*/architecture.md +/docs # Node /node_modules npm-debug.log diff --git a/README.md b/README.md index db2e6a3..4014324 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ + + + # Toju / Zoracord Desktop chat app with three parts: @@ -6,12 +9,6 @@ Desktop chat app with three parts: - `electron/` desktop shell, IPC, and local database - `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 1. Run `npm install` @@ -58,3 +55,7 @@ Inside `server/`: - `npm run dev` starts the server with reload - `npm run build` compiles to `dist/` - `npm run start` runs the compiled server + +# Images + + diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 0dbd7ca..0000000 --- a/docs/architecture.md +++ /dev/null @@ -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/ - / - 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. \ No newline at end of file diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1e75375..e4640ce 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -10,22 +10,22 @@ export const routes: Routes = [ { path: 'login', loadComponent: () => - import('./features/auth/login/login.component').then((module) => module.LoginComponent) + import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent) }, { path: 'register', 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', loadComponent: () => - import('./features/invite/invite.component').then((module) => module.InviteComponent) + import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent) }, { path: 'search', loadComponent: () => - import('./features/server-search/server-search.component').then( + import('./domains/server-directory/feature/server-search/server-search.component').then( (module) => module.ServerSearchComponent ) }, diff --git a/src/app/app.ts b/src/app/app.ts index 145c271..1d7a858 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -24,7 +24,7 @@ import { SettingsModalService } from './core/services/settings-modal.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ServersRailComponent } from './features/servers/servers-rail.component'; import { TitleBarComponent } from './features/shell/title-bar.component'; -import { FloatingVoiceControlsComponent } from './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 { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index 0bff1bb..e4fd2b4 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -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 { - 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 type { BanEntry } from '../../shared-kernel'; -export interface RoomMember { - id: string; - oderId?: string; - username: string; - displayName: string; - avatarUrl?: string; - role: UserRole; - joinedAt: number; - lastSeenAt: number; -} +export type { VoiceState, ScreenShareState } from '../../shared-kernel'; -export interface Channel { - id: string; - name: string; - type: ChannelType; - position: number; -} +export type { + ChatEventBase, + ChatEventType, + ChatEvent, + ChatInventoryItem +} from '../../shared-kernel'; -export interface Message { - id: string; - roomId: string; - channelId?: string; - senderId: string; - senderName: string; - content: string; - timestamp: number; - editedAt?: number; - reactions: Reaction[]; - isDeleted: boolean; - replyToId?: string; -} +export type { + SignalingMessage, + SignalingMessageType +} from '../../shared-kernel'; -export interface Reaction { - id: string; - messageId: string; - oderId: string; - userId: string; - emoji: string; - timestamp: number; -} +export type { + ChatAttachmentAnnouncement, + ChatAttachmentMeta +} from '../../shared-kernel'; -export interface Room { - id: string; - name: string; - description?: string; - topic?: string; - hostId: string; - password?: string; - hasPassword?: boolean; - isPrivate: boolean; - createdAt: number; - userCount: number; - maxUsers?: number; - icon?: string; - iconUpdatedAt?: number; - permissions?: RoomPermissions; - channels?: Channel[]; - members?: RoomMember[]; - sourceId?: string; - sourceName?: string; - sourceUrl?: string; -} - -export interface RoomSettings { - name: string; - description?: string; - topic?: string; - isPrivate: boolean; - password?: string; - hasPassword?: boolean; - maxUsers?: number; - rules?: string[]; -} - -export interface RoomPermissions { - adminsManageRooms?: boolean; - moderatorsManageRooms?: boolean; - adminsManageIcon?: boolean; - moderatorsManageIcon?: boolean; - allowVoice?: boolean; - allowScreenShare?: boolean; - allowFileUploads?: boolean; - slowModeInterval?: number; -} - -export interface BanEntry { - oderId: string; - userId: string; - roomId: string; - bannedBy: string; - displayName?: string; - reason?: string; - expiresAt?: number; - timestamp: number; -} - -export interface PeerConnection { - peerId: string; - userId: string; - status: 'connecting' | 'connected' | 'disconnected' | 'failed'; - dataChannel?: RTCDataChannel; - connection?: RTCPeerConnection; -} - -export interface VoiceState { - isConnected: boolean; - isMuted: boolean; - isDeafened: boolean; - isSpeaking: boolean; - isMutedByAdmin?: boolean; - volume?: number; - roomId?: string; - serverId?: string; -} - -export interface ScreenShareState { - isSharing: boolean; - streamId?: string; - sourceId?: string; - sourceName?: string; -} - -export type SignalingMessageType = - | 'offer' - | 'answer' - | 'ice-candidate' - | 'join' - | 'leave' - | 'chat' - | 'state-sync' - | 'kick' - | 'ban' - | 'host-change' - | 'room-update'; - -export interface SignalingMessage { - type: SignalingMessageType; - from: string; - to?: string; - payload: unknown; - timestamp: number; -} - -export type ChatEventType = - | 'message' - | 'chat-message' - | 'edit' - | 'message-edited' - | 'delete' - | 'message-deleted' - | 'reaction' - | 'reaction-added' - | 'reaction-removed' - | 'kick' - | 'ban' - | 'room-deleted' - | 'host-change' - | 'room-settings-update' - | 'voice-state' - | 'chat-inventory-request' - | 'chat-inventory' - | 'chat-sync-request-ids' - | 'chat-sync-batch' - | 'chat-sync-summary' - | 'chat-sync-request' - | 'chat-sync-full' - | 'file-announce' - | 'file-chunk' - | 'file-request' - | 'file-cancel' - | 'file-not-found' - | 'member-roster-request' - | 'member-roster' - | 'member-leave' - | 'voice-state-request' - | 'state-request' - | 'screen-state' - | 'screen-share-request' - | 'screen-share-stop' - | 'role-change' - | 'room-permissions-update' - | 'server-icon-summary' - | 'server-icon-request' - | 'server-icon-full' - | 'server-icon-update' - | 'server-state-request' - | 'server-state-full' - | 'unban' - | 'channels-update'; - -export interface ChatInventoryItem { - id: string; - ts: number; - rc: number; - ac?: number; -} - -export interface ChatAttachmentAnnouncement { - id: string; - filename: string; - size: number; - mime: string; - isImage: boolean; - uploaderPeerId?: string; -} - -export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement { - messageId: string; - filePath?: string; - savedPath?: string; -} - -/** Optional fields depend on `type`. */ -export interface ChatEvent { - type: ChatEventType; - fromPeerId?: string; - messageId?: string; - message?: Message; - reaction?: Reaction; - data?: string | Partial; - timestamp?: number; - targetUserId?: string; - roomId?: string; - items?: ChatInventoryItem[]; - ids?: string[]; - messages?: Message[]; - attachments?: Record; - 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; - permissions?: Partial; - voiceState?: Partial; - isScreenSharing?: boolean; - icon?: string; - iconUpdatedAt?: number; - role?: UserRole; - room?: Partial; - 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; -} +// ── domain re-exports ─────────────────────────────────────────────── +export type { ServerInfo } from '../../domains/server-directory'; diff --git a/src/app/core/realtime/index.ts b/src/app/core/realtime/index.ts index d066280..ee16ace 100644 --- a/src/app/core/realtime/index.ts +++ b/src/app/core/realtime/index.ts @@ -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 * from '../../infrastructure/realtime/realtime.constants'; export * from '../../infrastructure/realtime/realtime.types'; diff --git a/src/app/domains/README.md b/src/app/domains/README.md new file mode 100644 index 0000000..72ed0db --- /dev/null +++ b/src/app/domains/README.md @@ -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// +├── index.ts # Barrel — the ONLY file outsiders import +├── domain/ # Pure types, interfaces, business rules +│ ├── .models.ts +│ └── .logic.ts # Pure functions (no Angular, no side effects) +├── application/ # Angular services that orchestrate domain logic +│ └── .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/` (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//feature/` or `domains//ui/` | +| Add a settings subpanel | `domains//feature/settings/` | +| Add a top-level page or shell component | `features/` | +| Add persistence logic | `infrastructure/persistence/` or `domains//infrastructure/` | +| Add realtime/WebRTC logic | `infrastructure/realtime/` | diff --git a/src/app/domains/attachment/README.md b/src/app/domains/attachment/README.md new file mode 100644 index 0000000..76dc568 --- /dev/null +++ b/src/app/domains/attachment/README.md @@ -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. diff --git a/src/app/domains/attachment/domain/attachment-transfer.models.ts b/src/app/domains/attachment/domain/attachment-transfer.models.ts index 45596c3..26a5a57 100644 --- a/src/app/domains/attachment/domain/attachment-transfer.models.ts +++ b/src/app/domains/attachment/domain/attachment-transfer.models.ts @@ -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 & { type: 'file-announce'; diff --git a/src/app/domains/attachment/domain/attachment.models.ts b/src/app/domains/attachment/domain/attachment.models.ts index 166ee6a..e838d90 100644 --- a/src/app/domains/attachment/domain/attachment.models.ts +++ b/src/app/domains/attachment/domain/attachment.models.ts @@ -1,4 +1,4 @@ -import type { ChatAttachmentMeta } from '../../../core/models/index'; +import type { ChatAttachmentMeta } from '../../../shared-kernel'; export type AttachmentMeta = ChatAttachmentMeta; diff --git a/src/app/domains/auth/README.md b/src/app/domains/auth/README.md new file mode 100644 index 0000000..cd30683 --- /dev/null +++ b/src/app/domains/auth/README.md @@ -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. diff --git a/src/app/features/auth/login/login.component.html b/src/app/domains/auth/feature/login/login.component.html similarity index 100% rename from src/app/features/auth/login/login.component.html rename to src/app/domains/auth/feature/login/login.component.html diff --git a/src/app/features/auth/login/login.component.ts b/src/app/domains/auth/feature/login/login.component.ts similarity index 89% rename from src/app/features/auth/login/login.component.ts rename to src/app/domains/auth/feature/login/login.component.ts index c42837c..e7f2d87 100644 --- a/src/app/features/auth/login/login.component.ts +++ b/src/app/domains/auth/feature/login/login.component.ts @@ -11,11 +11,11 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideLogIn } from '@ng-icons/lucide'; -import { AuthService } from '../../../domains/auth'; -import { ServerDirectoryFacade } from '../../../domains/server-directory'; -import { UsersActions } from '../../../store/users/users.actions'; -import { User } from '../../../core/models/index'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; +import { AuthService } from '../../application/auth.service'; +import { ServerDirectoryFacade } from '../../../server-directory'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { User } from '../../../../shared-kernel'; +import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; @Component({ selector: 'app-login', diff --git a/src/app/features/auth/register/register.component.html b/src/app/domains/auth/feature/register/register.component.html similarity index 100% rename from src/app/features/auth/register/register.component.html rename to src/app/domains/auth/feature/register/register.component.html diff --git a/src/app/features/auth/register/register.component.ts b/src/app/domains/auth/feature/register/register.component.ts similarity index 89% rename from src/app/features/auth/register/register.component.ts rename to src/app/domains/auth/feature/register/register.component.ts index f7212fe..6b6a8f9 100644 --- a/src/app/features/auth/register/register.component.ts +++ b/src/app/domains/auth/feature/register/register.component.ts @@ -11,11 +11,11 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideUserPlus } from '@ng-icons/lucide'; -import { AuthService } from '../../../domains/auth'; -import { ServerDirectoryFacade } from '../../../domains/server-directory'; -import { UsersActions } from '../../../store/users/users.actions'; -import { User } from '../../../core/models/index'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; +import { AuthService } from '../../application/auth.service'; +import { ServerDirectoryFacade } from '../../../server-directory'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { User } from '../../../../shared-kernel'; +import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; @Component({ selector: 'app-register', diff --git a/src/app/features/auth/user-bar/user-bar.component.html b/src/app/domains/auth/feature/user-bar/user-bar.component.html similarity index 100% rename from src/app/features/auth/user-bar/user-bar.component.html rename to src/app/domains/auth/feature/user-bar/user-bar.component.html diff --git a/src/app/features/auth/user-bar/user-bar.component.ts b/src/app/domains/auth/feature/user-bar/user-bar.component.ts similarity index 92% rename from src/app/features/auth/user-bar/user-bar.component.ts rename to src/app/domains/auth/feature/user-bar/user-bar.component.ts index ebe56a0..58f545b 100644 --- a/src/app/features/auth/user-bar/user-bar.component.ts +++ b/src/app/domains/auth/feature/user-bar/user-bar.component.ts @@ -8,7 +8,7 @@ import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide'; -import { selectCurrentUser } from '../../../store/users/users.selectors'; +import { selectCurrentUser } from '../../../../store/users/users.selectors'; @Component({ selector: 'app-user-bar', diff --git a/src/app/domains/chat/README.md b/src/app/domains/chat/README.md new file mode 100644 index 0000000..69cdd1f --- /dev/null +++ b/src/app/domains/chat/README.md @@ -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". diff --git a/src/app/domains/chat/domain/message-sync.rules.ts b/src/app/domains/chat/domain/message-sync.rules.ts new file mode 100644 index 0000000..e68bdbd --- /dev/null +++ b/src/app/domains/chat/domain/message-sync.rules.ts @@ -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(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[] { + 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; +} diff --git a/src/app/domains/chat/domain/message.rules.ts b/src/app/domains/chat/domain/message.rules.ts new file mode 100644 index 0000000..f85b811 --- /dev/null +++ b/src/app/domains/chat/domain/message.rules.ts @@ -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; +} diff --git a/src/app/features/chat/chat-messages/chat-messages.component.html b/src/app/domains/chat/feature/chat-messages/chat-messages.component.html similarity index 100% rename from src/app/features/chat/chat-messages/chat-messages.component.html rename to src/app/domains/chat/feature/chat-messages/chat-messages.component.html diff --git a/src/app/features/chat/chat-messages/chat-messages.component.scss b/src/app/domains/chat/feature/chat-messages/chat-messages.component.scss similarity index 100% rename from src/app/features/chat/chat-messages/chat-messages.component.scss rename to src/app/domains/chat/feature/chat-messages/chat-messages.component.scss diff --git a/src/app/features/chat/chat-messages/chat-messages.component.ts b/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts similarity index 95% rename from src/app/features/chat/chat-messages/chat-messages.component.ts rename to src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index ad105a7..f00397e 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.ts +++ b/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -8,19 +8,19 @@ import { signal } from '@angular/core'; import { Store } from '@ngrx/store'; -import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; -import { RealtimeSessionFacade } from '../../../core/realtime'; -import { Attachment, AttachmentFacade } from '../../../domains/attachment'; -import { KlipyGif } from '../../../domains/chat'; -import { MessagesActions } from '../../../store/messages/messages.actions'; +import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; +import { RealtimeSessionFacade } from '../../../../core/realtime'; +import { Attachment, AttachmentFacade } from '../../../attachment'; +import { KlipyGif } from '../../application/klipy.service'; +import { MessagesActions } from '../../../../store/messages/messages.actions'; import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing -} from '../../../store/messages/messages.selectors'; -import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; -import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; -import { Message } from '../../../core/models'; +} from '../../../../store/messages/messages.selectors'; +import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors'; +import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; +import { Message } from '../../../../shared-kernel'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html b/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html similarity index 100% rename from src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html rename to src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.scss b/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.scss similarity index 100% rename from src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.scss rename to src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.scss diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts b/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts similarity index 97% rename from src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts rename to src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts index b039ffb..6ce6069 100644 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts +++ b/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts @@ -19,10 +19,10 @@ import { lucideSend, lucideX } from '@ng-icons/lucide'; -import type { ClipboardFilePayload } from '../../../../../core/platform/electron/electron-api.models'; -import { ElectronBridgeService } from '../../../../../core/platform/electron/electron-bridge.service'; -import { KlipyGif, KlipyService } from '../../../../../domains/chat'; -import { Message } from '../../../../../core/models'; +import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; +import { KlipyGif, KlipyService } from '../../../../application/klipy.service'; +import { Message } from '../../../../../../shared-kernel'; import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component'; import { ChatMarkdownService } from '../../services/chat-markdown.service'; import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models'; diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html b/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html similarity index 100% rename from src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html rename to src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.scss b/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.scss similarity index 100% rename from src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.scss rename to src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.scss diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts b/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts similarity index 98% rename from src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts rename to src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 229218a..a02c42b 100644 --- a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts +++ b/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -33,14 +33,14 @@ import { Attachment, AttachmentFacade, MAX_AUTO_SAVE_SIZE_BYTES -} from '../../../../../domains/attachment'; -import { KlipyService } from '../../../../../domains/chat'; -import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models'; +} from '../../../../../attachment'; +import { KlipyService } from '../../../../application/klipy.service'; +import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel'; import { ChatAudioPlayerComponent, ChatVideoPlayerComponent, UserAvatarComponent -} from '../../../../../shared'; +} from '../../../../../../shared'; import { ChatMessageDeleteEvent, ChatMessageEditEvent, diff --git a/src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.html b/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html similarity index 100% rename from src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.html rename to src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html diff --git a/src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.ts b/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts similarity index 98% rename from src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.ts rename to src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts index 0374b1a..4e5af65 100644 --- a/src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.ts +++ b/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts @@ -12,8 +12,8 @@ import { output, signal } from '@angular/core'; -import { Attachment } from '../../../../../domains/attachment'; -import { Message } from '../../../../../core/models'; +import { Attachment } from '../../../../../attachment'; +import { Message } from '../../../../../../shared-kernel'; import { ChatMessageDeleteEvent, ChatMessageEditEvent, diff --git a/src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.html b/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html similarity index 100% rename from src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.html rename to src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html diff --git a/src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.ts b/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts similarity index 94% rename from src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.ts rename to src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts index 48a7f8e..70e1d41 100644 --- a/src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.ts +++ b/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.ts @@ -10,8 +10,8 @@ import { lucideDownload, lucideX } from '@ng-icons/lucide'; -import { Attachment } from '../../../../../domains/attachment'; -import { ContextMenuComponent } from '../../../../../shared'; +import { Attachment } from '../../../../../attachment'; +import { ContextMenuComponent } from '../../../../../../shared'; import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models'; @Component({ diff --git a/src/app/features/chat/chat-messages/models/chat-messages.models.ts b/src/app/domains/chat/feature/chat-messages/models/chat-messages.models.ts similarity index 83% rename from src/app/features/chat/chat-messages/models/chat-messages.models.ts rename to src/app/domains/chat/feature/chat-messages/models/chat-messages.models.ts index 114434f..d7f2404 100644 --- a/src/app/features/chat/chat-messages/models/chat-messages.models.ts +++ b/src/app/domains/chat/feature/chat-messages/models/chat-messages.models.ts @@ -1,5 +1,5 @@ -import { Attachment } from '../../../../domains/attachment'; -import { Message } from '../../../../core/models'; +import { Attachment } from '../../../../attachment'; +import { Message } from '../../../../../shared-kernel'; export interface ChatMessageComposerSubmitEvent { content: string; diff --git a/src/app/features/chat/chat-messages/services/chat-markdown.service.ts b/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.ts similarity index 100% rename from src/app/features/chat/chat-messages/services/chat-markdown.service.ts rename to src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.ts diff --git a/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.html b/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html similarity index 100% rename from src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.html rename to src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html diff --git a/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.ts b/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts similarity index 98% rename from src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.ts rename to src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts index a3ff1f0..cec42a2 100644 --- a/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.ts +++ b/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts @@ -20,7 +20,7 @@ import { lucideSearch, lucideX } from '@ng-icons/lucide'; -import { KlipyGif, KlipyService } from '../../../domains/chat'; +import { KlipyGif, KlipyService } from '../../application/klipy.service'; @Component({ selector: 'app-klipy-gif-picker', diff --git a/src/app/features/chat/typing-indicator/typing-indicator.component.html b/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.html similarity index 100% rename from src/app/features/chat/typing-indicator/typing-indicator.component.html rename to src/app/domains/chat/feature/typing-indicator/typing-indicator.component.html diff --git a/src/app/features/chat/typing-indicator/typing-indicator.component.ts b/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts similarity index 95% rename from src/app/features/chat/typing-indicator/typing-indicator.component.ts rename to src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts index 6fcf73f..be659aa 100644 --- a/src/app/features/chat/typing-indicator/typing-indicator.component.ts +++ b/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts @@ -8,8 +8,8 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; -import { RealtimeSessionFacade } from '../../../core/realtime'; -import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; +import { RealtimeSessionFacade } from '../../../../core/realtime'; +import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { merge, interval, diff --git a/src/app/features/chat/user-list/user-list.component.html b/src/app/domains/chat/feature/user-list/user-list.component.html similarity index 100% rename from src/app/features/chat/user-list/user-list.component.html rename to src/app/domains/chat/feature/user-list/user-list.component.html diff --git a/src/app/features/chat/user-list/user-list.component.ts b/src/app/domains/chat/feature/user-list/user-list.component.ts similarity index 95% rename from src/app/features/chat/user-list/user-list.component.ts rename to src/app/domains/chat/feature/user-list/user-list.component.ts index 7e44527..e1b5a7f 100644 --- a/src/app/features/chat/user-list/user-list.component.ts +++ b/src/app/domains/chat/feature/user-list/user-list.component.ts @@ -22,14 +22,14 @@ import { lucideVolumeX } from '@ng-icons/lucide'; -import { UsersActions } from '../../../store/users/users.actions'; +import { UsersActions } from '../../../../store/users/users.actions'; import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin -} from '../../../store/users/users.selectors'; -import { User } from '../../../core/models/index'; -import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; +} from '../../../../store/users/users.selectors'; +import { User } from '../../../../shared-kernel'; +import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared'; @Component({ selector: 'app-user-list', diff --git a/src/app/domains/chat/index.ts b/src/app/domains/chat/index.ts index 6e77d33..804330b 100644 --- a/src/app/domains/chat/index.ts +++ b/src/app/domains/chat/index.ts @@ -1 +1,7 @@ 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'; diff --git a/src/app/domains/screen-share/README.md b/src/app/domains/screen-share/README.md new file mode 100644 index 0000000..4c93f89 --- /dev/null +++ b/src/app/domains/screen-share/README.md @@ -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() +``` diff --git a/src/app/domains/screen-share/domain/screen-share.config.ts b/src/app/domains/screen-share/domain/screen-share.config.ts index b7977f5..2319248 100644 --- a/src/app/domains/screen-share/domain/screen-share.config.ts +++ b/src/app/domains/screen-share/domain/screen-share.config.ts @@ -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 { - includeSystemAudio: boolean; - quality: ScreenShareQuality; -} - -export interface ScreenShareQualityPreset { - label: string; - description: string; - width: number; - 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 { + 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 }; - -export const SCREEN_SHARE_QUALITY_PRESETS: Record = { - 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 -})); diff --git a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html b/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.html similarity index 100% rename from src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html rename to src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.html diff --git a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts b/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts similarity index 95% rename from src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts rename to src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts index 5b96256..f30715d 100644 --- a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts +++ b/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts @@ -19,11 +19,11 @@ import { lucideMonitor } from '@ng-icons/lucide'; -import { ScreenShareFacade } from '../../../domains/screen-share'; -import { selectOnlineUsers } from '../../../store/users/users.selectors'; -import { User } from '../../../core/models/index'; -import { DEFAULT_VOLUME } from '../../../core/constants'; -import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service'; +import { ScreenShareFacade } from '../../application/screen-share.facade'; +import { selectOnlineUsers } from '../../../../store/users/users.selectors'; +import { User } from '../../../../shared-kernel'; +import { DEFAULT_VOLUME } from '../../../../core/constants'; +import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service'; @Component({ selector: 'app-screen-share-viewer', diff --git a/src/app/features/voice/screen-share-workspace/screen-share-playback.service.ts b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-playback.service.ts similarity index 100% rename from src/app/features/voice/screen-share-workspace/screen-share-playback.service.ts rename to src/app/domains/screen-share/feature/screen-share-workspace/screen-share-playback.service.ts diff --git a/src/app/features/voice/screen-share-workspace/screen-share-stream-tile.component.html b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-stream-tile.component.html similarity index 100% rename from src/app/features/voice/screen-share-workspace/screen-share-stream-tile.component.html rename to src/app/domains/screen-share/feature/screen-share-workspace/screen-share-stream-tile.component.html diff --git a/src/app/features/voice/screen-share-workspace/screen-share-stream-tile.component.ts b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-stream-tile.component.ts similarity index 99% rename from src/app/features/voice/screen-share-workspace/screen-share-stream-tile.component.ts rename to src/app/domains/screen-share/feature/screen-share-workspace/screen-share-stream-tile.component.ts index bb2d05c..5b15456 100644 --- a/src/app/features/voice/screen-share-workspace/screen-share-stream-tile.component.ts +++ b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-stream-tile.component.ts @@ -21,7 +21,7 @@ import { lucideVolumeX } from '@ng-icons/lucide'; -import { UserAvatarComponent } from '../../../shared'; +import { UserAvatarComponent } from '../../../../shared'; import { ScreenSharePlaybackService } from './screen-share-playback.service'; import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models'; diff --git a/src/app/features/voice/screen-share-workspace/screen-share-workspace.component.html b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.html similarity index 100% rename from src/app/features/voice/screen-share-workspace/screen-share-workspace.component.html rename to src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.html diff --git a/src/app/features/voice/screen-share-workspace/screen-share-workspace.component.ts b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.ts similarity index 97% rename from src/app/features/voice/screen-share-workspace/screen-share-workspace.component.ts rename to src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.ts index b7f63d2..eb1ede3 100644 --- a/src/app/features/voice/screen-share-workspace/screen-share-workspace.component.ts +++ b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.ts @@ -29,25 +29,22 @@ import { lucideX } from '@ng-icons/lucide'; -import { User } from '../../../core/models'; +import { User } from '../../../../shared-kernel'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage, VoiceSessionFacade, VoiceWorkspacePosition, VoiceWorkspaceService -} from '../../../domains/voice-session'; -import { VoiceConnectionFacade } from '../../../domains/voice-connection'; -import { - ScreenShareFacade, - ScreenShareQuality, - ScreenShareStartOptions -} from '../../../domains/screen-share'; -import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; -import { UsersActions } from '../../../store/users/users.actions'; -import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; -import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared'; -import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service'; +} from '../../../../domains/voice-session'; +import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; +import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service'; +import { ScreenShareFacade } from '../../application/screen-share.facade'; +import { ScreenShareQuality, ScreenShareStartOptions } from '../../domain/screen-share.config'; +import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors'; +import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../../shared'; import { ScreenSharePlaybackService } from './screen-share-playback.service'; import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component'; import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models'; diff --git a/src/app/features/voice/screen-share-workspace/screen-share-workspace.models.ts b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.models.ts similarity index 74% rename from src/app/features/voice/screen-share-workspace/screen-share-workspace.models.ts rename to src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.models.ts index 834afb5..e8a530c 100644 --- a/src/app/features/voice/screen-share-workspace/screen-share-workspace.models.ts +++ b/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.models.ts @@ -1,4 +1,4 @@ -import { User } from '../../../core/models'; +import { User } from '../../../../shared-kernel'; export interface ScreenShareWorkspaceStreamItem { id: string; diff --git a/src/app/domains/screen-share/index.ts b/src/app/domains/screen-share/index.ts index 0f348e4..41d0d48 100644 --- a/src/app/domains/screen-share/index.ts +++ b/src/app/domains/screen-share/index.ts @@ -1,3 +1,8 @@ export * from './application/screen-share.facade'; export * from './application/screen-share-source-picker.service'; 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'; diff --git a/src/app/domains/server-directory/README.md b/src/app/domains/server-directory/README.md new file mode 100644 index 0000000..7604c62 --- /dev/null +++ b/src/app/domains/server-directory/README.md @@ -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. diff --git a/src/app/domains/server-directory/application/server-directory.facade.ts b/src/app/domains/server-directory/application/server-directory.facade.ts index 2edd23c..fe802bb 100644 --- a/src/app/domains/server-directory/application/server-directory.facade.ts +++ b/src/app/domains/server-directory/application/server-directory.facade.ts @@ -5,7 +5,7 @@ import { } from '@angular/core'; import { Observable } from 'rxjs'; 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 { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service'; import type { @@ -14,6 +14,7 @@ import type { KickServerMemberRequest, ServerEndpoint, ServerEndpointVersions, + ServerInfo, ServerInviteInfo, ServerJoinAccessRequest, ServerJoinAccessResponse, diff --git a/src/app/domains/server-directory/domain/server-directory.models.ts b/src/app/domains/server-directory/domain/server-directory.models.ts index bbcf54b..312d6c5 100644 --- a/src/app/domains/server-directory/domain/server-directory.models.ts +++ b/src/app/domains/server-directory/domain/server-directory.models.ts @@ -1,7 +1,25 @@ -import type { ServerInfo } from '../../../core/models'; - 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 { key?: string; name?: string; diff --git a/src/app/features/invite/invite.component.html b/src/app/domains/server-directory/feature/invite/invite.component.html similarity index 100% rename from src/app/features/invite/invite.component.html rename to src/app/domains/server-directory/feature/invite/invite.component.html diff --git a/src/app/features/invite/invite.component.ts b/src/app/domains/server-directory/feature/invite/invite.component.ts similarity index 89% rename from src/app/features/invite/invite.component.ts rename to src/app/domains/server-directory/feature/invite/invite.component.ts index a62ef39..4dc2fab 100644 --- a/src/app/features/invite/invite.component.ts +++ b/src/app/domains/server-directory/feature/invite/invite.component.ts @@ -8,14 +8,14 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { Store } from '@ngrx/store'; -import { RoomsActions } from '../../store/rooms/rooms.actions'; -import { UsersActions } from '../../store/users/users.actions'; -import { selectCurrentUser } from '../../store/users/users.selectors'; -import type { ServerInviteInfo } from '../../domains/server-directory'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; -import { DatabaseService } from '../../infrastructure/persistence'; -import { ServerDirectoryFacade } from '../../domains/server-directory'; -import { User } from '../../core/models/index'; +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { selectCurrentUser } from '../../../../store/users/users.selectors'; +import type { ServerInviteInfo } from '../../domain/server-directory.models'; +import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; +import { DatabaseService } from '../../../../infrastructure/persistence'; +import { ServerDirectoryFacade } from '../../application/server-directory.facade'; +import { User } from '../../../../shared-kernel'; @Component({ selector: 'app-invite', diff --git a/src/app/features/server-search/server-search.component.html b/src/app/domains/server-directory/feature/server-search/server-search.component.html similarity index 100% rename from src/app/features/server-search/server-search.component.html rename to src/app/domains/server-directory/feature/server-search/server-search.component.html diff --git a/src/app/features/server-search/server-search.component.ts b/src/app/domains/server-directory/feature/server-search/server-search.component.ts similarity index 93% rename from src/app/features/server-search/server-search.component.ts rename to src/app/domains/server-directory/feature/server-search/server-search.component.ts index 3e360b9..b8ee74f 100644 --- a/src/app/features/server-search/server-search.component.ts +++ b/src/app/domains/server-directory/feature/server-search/server-search.component.ts @@ -26,24 +26,21 @@ import { lucideSettings } from '@ng-icons/lucide'; -import { RoomsActions } from '../../store/rooms/rooms.actions'; +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms -} from '../../store/rooms/rooms.selectors'; -import { - Room, - ServerInfo, - User -} from '../../core/models/index'; -import { SettingsModalService } from '../../core/services/settings-modal.service'; -import { DatabaseService } from '../../infrastructure/persistence'; -import { ServerDirectoryFacade } from '../../domains/server-directory'; -import { selectCurrentUser } from '../../store/users/users.selectors'; -import { ConfirmDialogComponent } from '../../shared'; -import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; +} from '../../../../store/rooms/rooms.selectors'; +import { Room, User } from '../../../../shared-kernel'; +import { SettingsModalService } from '../../../../core/services/settings-modal.service'; +import { DatabaseService } from '../../../../infrastructure/persistence'; +import { type ServerInfo } from '../../domain/server-directory.models'; +import { ServerDirectoryFacade } from '../../application/server-directory.facade'; +import { selectCurrentUser } from '../../../../store/users/users.selectors'; +import { ConfirmDialogComponent } from '../../../../shared'; +import { hasRoomBanForUser } from '../../../../core/helpers/room-ban.helpers'; @Component({ selector: 'app-server-search', diff --git a/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts b/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts index 0955c3b..75abfc4 100644 --- a/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts +++ b/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts @@ -8,13 +8,14 @@ import { throwError } from 'rxjs'; 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 type { BanServerMemberRequest, CreateServerInviteRequest, KickServerMemberRequest, ServerEndpoint, + ServerInfo, ServerInviteInfo, ServerJoinAccessRequest, ServerJoinAccessResponse, diff --git a/src/app/domains/voice-connection/README.md b/src/app/domains/voice-connection/README.md new file mode 100644 index 0000000..fe3f362 --- /dev/null +++ b/src/app/domains/voice-connection/README.md @@ -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
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`) 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
0 - 200%] + Gain --> Dest[MediaStreamAudioDestinationNode] + Dest --> Audio[HTMLAudioElement
.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 `