This commit is contained in:
2026-03-20 03:05:29 +01:00
parent 429bb9d8ff
commit fe9c1dd1c0
139 changed files with 6308 additions and 4854 deletions

View File

@@ -6,6 +6,12 @@ Desktop chat app with three parts:
- `electron/` desktop shell, IPC, and local database - `electron/` desktop shell, IPC, and local database
- `server/` directory server, join request API, and websocket events - `server/` directory server, join request API, and websocket events
## Architecture
- Renderer architecture and refactor conventions live in `docs/architecture.md`
- Electron renderer integrations should go through `src/app/core/platform/electron/`
- Pure shared logic belongs in `src/app/core/helpers/` and reusable contracts belong in `src/app/core/models/`
## Install ## Install
1. Run `npm install` 1. Run `npm install`

139
docs/architecture.md Normal file
View File

@@ -0,0 +1,139 @@
# Frontend Architecture
This document defines the target structure for the Angular renderer and the boundaries between web-safe code, Electron adapters, reusable logic, and feature UI.
## Goals
- Keep feature code easy to navigate by grouping it around user-facing domains.
- Push platform-specific behavior behind explicit adapters.
- Move pure logic into helpers and dedicated model files instead of embedding it in components and large services.
- Split large services into smaller units with one clear responsibility each.
## Target Structure
```text
src/app/
app.ts
domains/
<domain>/
application/ # facades and use-case orchestration
domain/ # models and pure domain logic
infrastructure/ # adapters, api clients, persistence
feature/ # smart domain UI containers
ui/ # dumb/presentational domain UI
infrastructure/
persistence/ # shared storage adapters and persistence facades
realtime/ # shared signaling, peer transport, media runtime state
core/
constants.ts # cross-domain technical constants only
helpers/ # transitional pure helpers still being moved out
models/ # reusable cross-domain contracts only
platform/
platform.service.ts # runtime/environment detection adapters
external-link.service.ts # browser/electron navigation adapters
electron/ # renderer-side adapters for preload / Electron APIs
realtime/ # compatibility/public import boundary for realtime
services/ # technical cross-domain services only
features/ # transitional feature area while slices move into domains/*/feature
shared/
ui/ # shared presentational primitives
utils/ # shared pure utilities
store/ # ngrx reducers, effects, selectors, actions
```
## Layering Rules
Dependency direction must stay one-way:
1. `domains/*/feature`, `domains/*/ui`, and transitional `features/` may depend on `domains/`, `infrastructure/`, `core/`, `shared/`, and `store/`.
2. `domains/*/application` may depend on its own `domain/`, `infrastructure/`, top-level `infrastructure/`, `core/`, and `store/`.
3. `domains/*/domain` should stay framework-light and hold domain models plus pure logic.
4. `domains/*/infrastructure` may depend on `core/platform/`, browser APIs, HTTP, persistence, and external adapters.
5. Top-level `infrastructure/` should hold shared technical runtime implementations; `core/` should hold cross-domain utilities, compatibility entry points, and platform adapters rather than full runtime subsystems.
6. `core/models/` should only hold shared cross-domain contracts. Domain-specific models belong inside their domain folder.
## Responsibility Split
Use these roles consistently when a domain starts to absorb too many concerns:
- `application`: Angular-facing facades and use-case orchestration.
- `domain`: contracts, policies, calculations, mapping rules, and other pure domain logic.
- `infrastructure`: platform adapters, storage, transport, HTTP, IPC, and persistence boundaries.
- `feature`: smart components that wire store, routing, and facades.
- `ui`: presentational components with minimal business knowledge.
## Platform Boundary
Renderer code should not access `window.electronAPI` directly except inside the Electron adapter layer.
Current convention:
- Use `core/platform/electron/electron-api.models.ts` for Angular-side Electron typings.
- Use `core/platform/electron/electron-bridge.service.ts` to reach preload APIs from Angular code.
- Keep runtime detection and browser/Electron wrappers such as `core/platform/platform.service.ts` and `core/platform/external-link.service.ts` inside `core/platform/` rather than `core/services/`.
- Keep Electron-only persistence and file-system logic inside dedicated adapter services such as `domains/attachment/infrastructure/attachment-storage.service.ts`.
This keeps feature and domain code platform-agnostic and makes the browser runtime easier to reason about.
## Models And Pure Logic
- Attachment runtime types now live in `domains/attachment/domain/attachment.models.ts`.
- Attachment download-policy rules now live in `domains/attachment/domain/attachment.logic.ts`.
- Attachment file-path sanitizing and storage-bucket selection live in `domains/attachment/infrastructure/attachment-storage.helpers.ts`.
- New domain types should be placed in a dedicated model file inside their domain folder when they are not shared across domains.
- Only cross-domain contracts should stay in `core/models/`.
## Incremental Refactor Path
The repo is large enough that refactoring must stay incremental. Preferred order:
1. Extract platform adapters from direct renderer global access.
2. Split persistence and cache behavior out of large orchestration services.
3. Move reusable types and helper logic into dedicated files.
4. Break remaining multi-responsibility facades into managers or coordinators.
## Current Baseline
The current refactor establishes these patterns:
- Shared Electron bridge for Angular services and root app wiring.
- Attachment now lives under `domains/attachment/` with `application/attachment.facade.ts` as the public Angular-facing boundary.
- Attachment application orchestration is now split across `domains/attachment/application/attachment-manager.service.ts`, `attachment-transfer.service.ts`, `attachment-persistence.service.ts`, and `attachment-runtime.store.ts`.
- Attachment runtime types and pure transfer-policy logic live in `domains/attachment/domain/`.
- Attachment disk persistence and storage helpers live in `domains/attachment/infrastructure/`.
- Shared browser/Electron persistence adapters now live in `infrastructure/persistence/`, with `DatabaseService` as the renderer-facing facade over IndexedDB and Electron CQRS-backed SQLite.
- Auth HTTP/login orchestration now lives under `domains/auth/application/auth.service.ts`.
- Chat GIF search and KLIPY integration now live under `domains/chat/application/klipy.service.ts`.
- Voice-session now lives under `domains/voice-session/` with `application/voice-session.facade.ts` as the public Angular-facing boundary.
- Voice-session models and pure route/room mapping logic live in `domains/voice-session/domain/`.
- Voice workspace UI state now lives in `domains/voice-session/application/voice-workspace.service.ts`, and persisted voice/screen-share preferences now live in `domains/voice-session/infrastructure/voice-settings.storage.ts`.
- Voice activity tracking now lives in `domains/voice-connection/application/voice-activity.service.ts`.
- Server-directory now lives under `domains/server-directory/` with `application/server-directory.facade.ts` as the public Angular-facing boundary.
- Server-directory endpoint state now lives in `domains/server-directory/application/server-endpoint-state.service.ts`.
- Server-directory contracts and user-facing compatibility messaging live in `domains/server-directory/domain/`.
- Endpoint default URL normalization and built-in endpoint templates live in `domains/server-directory/domain/server-endpoint-defaults.ts`.
- Endpoint localStorage persistence, HTTP fan-out, compatibility checks, and health probes live in `domains/server-directory/infrastructure/`.
- `infrastructure/realtime/realtime-session.service.ts` now holds the shared realtime runtime service.
- `core/realtime/index.ts` is the compatibility/public import surface and re-exports that runtime service as `RealtimeSessionFacade` for technical cross-domain consumers.
- `domains/voice-connection/` now exposes `application/voice-connection.facade.ts` as the voice-specific Angular-facing boundary.
- `domains/screen-share/` now exposes `application/screen-share.facade.ts` plus `application/screen-share-source-picker.service.ts`, and `domain/screen-share.config.ts` is the real source for screen-share presets/options plus `ELECTRON_ENTIRE_SCREEN_SOURCE_NAME`.
- Shared transport contracts and the low-level signaling and peer-connection stack now live under `infrastructure/realtime/`.
- Realtime debug network metric collection now lives in `infrastructure/realtime/logging/debug-network-metrics.ts`; the debug console reads that infrastructure state rather than keeping the metric store inside `core/services/`.
- Shared media handling, voice orchestration helpers, noise reduction, and screen-share capture adapters now live in `infrastructure/realtime/media/`.
- The old `domains/webrtc/*` and `core/services/webrtc.service.ts` compatibility shims have been removed after all in-repo callers moved to `core/realtime`, `domains/voice-connection/`, and `domains/screen-share/`.
- Multi-server signaling topology and signaling-manager lifecycle extracted from `WebRTCService` into `ServerSignalingCoordinator`.
- Angular-facing signal and connection-state bookkeeping extracted from `WebRTCService` into `WebRtcStateController`.
- Peer/media event streams, peer messaging, remote stream access, and screen-share entry points extracted from `WebRTCService` into `PeerMediaFacade`.
- Lower-level signaling connect/send/identify helpers extracted from `WebRTCService` into `SignalingTransportHandler`.
- Incoming signaling message semantics extracted from `WebRTCService` into `IncomingSignalingMessageHandler`.
- Outbound server-membership signaling commands extracted from `WebRTCService` into `ServerMembershipSignalingHandler`.
- Remote screen-share request state and control-plane handling extracted from `WebRTCService` into `RemoteScreenShareRequestController`.
- Voice session and heartbeat orchestration extracted from `WebRTCService` into `VoiceSessionController`.
- Desktop client-version lookup and endpoint compatibility checks for server-directory live in `domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts`.
- Endpoint health probing and fallback transport for server-directory live in `domains/server-directory/infrastructure/server-endpoint-health.service.ts`.
- Server-directory HTTP fan-out, endpoint resolution, and API response normalization live in `domains/server-directory/infrastructure/server-directory-api.service.ts`.
## Next Candidates
- Migrate remaining voice and room UI from transitional `features/` into domain-local `feature/` and `ui/` folders.
- Migrate remaining WebRTC-facing UI from transitional `features/` and `shared/` locations into domain-local `feature/` and `ui/` folders where it improves ownership.

Binary file not shown.

View File

@@ -14,13 +14,14 @@ import {
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { DatabaseService } from './core/services/database.service'; import { DatabaseService } from './infrastructure/persistence';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { ServerDirectoryService } from './core/services/server-directory.service'; import { ServerDirectoryFacade } from './domains/server-directory';
import { TimeSyncService } from './core/services/time-sync.service'; import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionService } from './core/services/voice-session.service'; import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/services/external-link.service'; import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; 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 { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component'; import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component'; import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
@@ -36,15 +37,6 @@ import {
STORAGE_KEY_LAST_VISITED_ROUTE STORAGE_KEY_LAST_VISITED_ROUTE
} from './core/constants'; } from './core/constants';
interface DeepLinkElectronApi {
consumePendingDeepLink?: () => Promise<string | null>;
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
}
type DeepLinkWindow = Window & {
electronAPI?: DeepLinkElectronApi;
};
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
@@ -68,11 +60,12 @@ export class App implements OnInit, OnDestroy {
private databaseService = inject(DatabaseService); private databaseService = inject(DatabaseService);
private router = inject(Router); private router = inject(Router);
private servers = inject(ServerDirectoryService); private servers = inject(ServerDirectoryFacade);
private settingsModal = inject(SettingsModalService); private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService); private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService); private voiceSession = inject(VoiceSessionFacade);
private externalLinks = inject(ExternalLinkService); private externalLinks = inject(ExternalLinkService);
private electronBridge = inject(ElectronBridgeService);
private deepLinkCleanup: (() => void) | null = null; private deepLinkCleanup: (() => void) | null = null;
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
@@ -155,7 +148,7 @@ export class App implements OnInit, OnDestroy {
} }
private async setupDesktopDeepLinks(): Promise<void> { private async setupDesktopDeepLinks(): Promise<void> {
const electronApi = this.getDeepLinkElectronApi(); const electronApi = this.electronBridge.getApi();
if (!electronApi) { if (!electronApi) {
return; return;
@@ -186,12 +179,6 @@ export class App implements OnInit, OnDestroy {
}); });
} }
private getDeepLinkElectronApi(): DeepLinkElectronApi | null {
return typeof window !== 'undefined'
? (window as DeepLinkWindow).electronAPI ?? null
: null;
}
private isPublicRoute(url: string): boolean { private isPublicRoute(url: string): boolean {
return url === '/login' || return url === '/login' ||
url === '/register' || url === '/register' ||

View File

@@ -0,0 +1,150 @@
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
active: boolean;
monitorCaptureSupported: boolean;
screenShareSinkName: string;
screenShareMonitorSourceName: string;
voiceSinkName: string;
reason?: string;
}
export interface LinuxScreenShareMonitorCaptureInfo {
bitsPerSample: number;
captureId: string;
channelCount: number;
sampleRate: number;
sourceName: string;
}
export interface LinuxScreenShareMonitorAudioChunkPayload {
captureId: string;
chunk: Uint8Array;
}
export interface LinuxScreenShareMonitorAudioEndedPayload {
captureId: string;
reason?: string;
}
export interface ClipboardFilePayload {
data: string;
lastModified: number;
mime: string;
name: string;
path?: string;
}
export type AutoUpdateMode = 'auto' | 'off' | 'version';
export type DesktopUpdateStatus =
| 'idle'
| 'disabled'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
export interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
configuredManifestUrls: string[];
currentVersion: string;
defaultManifestUrls: string[];
isSupported: boolean;
lastCheckedAt: number | null;
latestVersion: string | null;
manifestUrl: string | null;
manifestUrls: string[];
minimumServerVersion: string | null;
preferredVersion: string | null;
restartRequired: boolean;
serverBlocked: boolean;
serverBlockMessage: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
status: DesktopUpdateStatus;
statusMessage: string | null;
targetVersion: string | null;
}
export interface DesktopSettingsSnapshot {
autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}
export interface DesktopSettingsPatch {
autoUpdateMode?: AutoUpdateMode;
autoStart?: boolean;
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
vaapiVideoEncode?: boolean;
}
export interface ElectronCommand {
type: string;
payload: unknown;
}
export interface ElectronQuery {
type: string;
payload: unknown;
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>;
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
fileExists: (filePath: string) => Promise<boolean>;
deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
}
export type ElectronWindow = Window & {
electronAPI?: ElectronApi;
};

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import type { ElectronApi } from './electron-api.models';
import { getElectronApi } from './get-electron-api';
@Injectable({ providedIn: 'root' })
export class ElectronBridgeService {
get isAvailable(): boolean {
return this.getApi() !== null;
}
getApi(): ElectronApi | null {
return getElectronApi();
}
requireApi(): ElectronApi {
const api = this.getApi();
if (!api) {
throw new Error('Electron API is not available in this runtime.');
}
return api;
}
}

View File

@@ -0,0 +1,7 @@
import type { ElectronApi, ElectronWindow } from './electron-api.models';
export function getElectronApi(): ElectronApi | null {
return typeof window !== 'undefined'
? (window as ElectronWindow).electronAPI ?? null
: null;
}

View File

@@ -1,13 +1,5 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { PlatformService } from './platform.service'; import { ElectronBridgeService } from './electron/electron-bridge.service';
interface ExternalLinkElectronApi {
openExternal?: (url: string) => Promise<boolean>;
}
type ExternalLinkWindow = Window & {
electronAPI?: ExternalLinkElectronApi;
};
/** /**
* Opens URLs in the system default browser (Electron) or a new tab (browser). * Opens URLs in the system default browser (Electron) or a new tab (browser).
@@ -17,18 +9,21 @@ type ExternalLinkWindow = Window & {
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ExternalLinkService { export class ExternalLinkService {
private platform = inject(PlatformService); private readonly electronBridge = inject(ElectronBridgeService);
/** Open a URL externally. Only http/https URLs are allowed. */ /** Open a URL externally. Only http/https URLs are allowed. */
open(url: string): void { open(url: string): void {
if (!url || !(url.startsWith('http://') || url.startsWith('https://'))) if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
return; return;
if (this.platform.isElectron) { const electronApi = this.electronBridge.getApi();
(window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
} else { if (electronApi) {
window.open(url, '_blank', 'noopener,noreferrer'); void electronApi.openExternal(url);
return;
} }
window.open(url, '_blank', 'noopener,noreferrer');
} }
/** /**
@@ -41,22 +36,19 @@ export class ExternalLinkService {
if (!target) if (!target)
return false; return false;
const href = target.href; // resolved full URL const href = target.href;
if (!href) if (!href)
return false; return false;
// Skip non-navigable URLs
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:')) if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
return false; return false;
// Skip same-page anchors
const rawAttr = target.getAttribute('href'); const rawAttr = target.getAttribute('href');
if (rawAttr?.startsWith('#')) if (rawAttr?.startsWith('#'))
return false; return false;
// Skip Angular router links
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link')) if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
return false; return false;

View File

@@ -0,0 +1,2 @@
export * from './platform.service';
export * from './external-link.service';

View File

@@ -0,0 +1,15 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from './electron/electron-bridge.service';
@Injectable({ providedIn: 'root' })
export class PlatformService {
readonly isElectron: boolean;
readonly isBrowser: boolean;
private readonly electronBridge = inject(ElectronBridgeService);
constructor() {
this.isElectron = this.electronBridge.isAvailable;
this.isBrowser = !this.isElectron;
}
}

View File

@@ -0,0 +1,3 @@
export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service';
export * from '../../infrastructure/realtime/realtime.constants';
export * from '../../infrastructure/realtime/realtime.types';

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
/* eslint-disable complexity, padding-line-between-statements */ /* eslint-disable complexity, padding-line-between-statements */
import { getDebugNetworkMetricSnapshot } from '../debug-network-metrics.service'; import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
import type { Room, User } from '../../models/index'; import type { Room, User } from '../../models/index';
import { import {
LOCAL_NETWORK_NODE_ID, LOCAL_NETWORK_NODE_ID,

View File

@@ -5,65 +5,16 @@ import {
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
import { PlatformService } from './platform.service'; import { PlatformService } from '../platform';
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service'; import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
import {
type AutoUpdateMode = 'auto' | 'off' | 'version'; type AutoUpdateMode,
type DesktopUpdateStatus = type DesktopUpdateServerContext,
| 'idle' type DesktopUpdateServerVersionStatus,
| 'disabled' type DesktopUpdateState,
| 'checking' type ElectronApi
| 'downloading' } from '../platform/electron/electron-api.models';
| 'up-to-date' import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
configuredManifestUrls: string[];
currentVersion: string;
defaultManifestUrls: string[];
isSupported: boolean;
lastCheckedAt: number | null;
latestVersion: string | null;
manifestUrl: string | null;
manifestUrls: string[];
minimumServerVersion: string | null;
preferredVersion: string | null;
restartRequired: boolean;
serverBlocked: boolean;
serverBlockMessage: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
status: DesktopUpdateStatus;
statusMessage: string | null;
targetVersion: string | null;
}
interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
interface DesktopUpdateElectronApi {
checkForAppUpdates?: () => Promise<DesktopUpdateState>;
configureAutoUpdateContext?: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
getAutoUpdateState?: () => Promise<DesktopUpdateState>;
onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void;
restartToApplyUpdate?: () => Promise<boolean>;
setDesktopSettings?: (patch: {
autoUpdateMode?: AutoUpdateMode;
manifestUrls?: string[];
preferredVersion?: string | null;
}) => Promise<unknown>;
}
interface ServerHealthResponse { interface ServerHealthResponse {
releaseManifestUrl?: string; releaseManifestUrl?: string;
@@ -77,10 +28,6 @@ interface ServerHealthSnapshot {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
type DesktopUpdateWindow = Window & {
electronAPI?: DesktopUpdateElectronApi;
};
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000; const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
const SERVER_CONTEXT_TIMEOUT_MS = 5_000; const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
@@ -153,7 +100,8 @@ export class DesktopAppUpdateService {
readonly state = signal<DesktopUpdateState>(createInitialState()); readonly state = signal<DesktopUpdateState>(createInitialState());
private injector = inject(Injector); private injector = inject(Injector);
private servers = inject(ServerDirectoryService); private servers = inject(ServerDirectoryFacade);
private electronBridge = inject(ElectronBridgeService);
private initialized = false; private initialized = false;
private refreshTimerId: number | null = null; private refreshTimerId: number | null = null;
private removeStateListener: (() => void) | null = null; private removeStateListener: (() => void) | null = null;
@@ -393,9 +341,7 @@ export class DesktopAppUpdateService {
} catch {} } catch {}
} }
private getElectronApi(): DesktopUpdateElectronApi | null { private getElectronApi(): ElectronApi | null {
return typeof window !== 'undefined' return this.electronBridge.getApi();
? (window as DesktopUpdateWindow).electronAPI ?? null
: null;
} }
} }

View File

@@ -1,14 +1,4 @@
export * from './notification-audio.service'; export * from './notification-audio.service';
export * from './platform.service';
export * from './browser-database.service';
export * from './electron-database.service';
export * from './database.service';
export * from '../models/debugging.models'; export * from '../models/debugging.models';
export * from './debugging/debugging.service'; export * from './debugging/debugging.service';
export * from './webrtc.service';
export * from './server-directory.service';
export * from './klipy.service';
export * from './voice-session.service';
export * from './voice-activity.service';
export * from './external-link.service';
export * from './settings-modal.service'; export * from './settings-modal.service';

View File

@@ -1,18 +0,0 @@
import { Injectable } from '@angular/core';
type ElectronPlatformWindow = Window & {
electronAPI?: unknown;
};
@Injectable({ providedIn: 'root' })
export class PlatformService {
readonly isElectron: boolean;
readonly isBrowser: boolean;
constructor() {
this.isElectron =
typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI;
this.isBrowser = !this.isElectron;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
/**
* Barrel export for the WebRTC sub-module.
*
* Other modules should import from here:
* import { ... } from './webrtc';
*/
export * from './webrtc.constants';
export * from './webrtc.types';
export * from './webrtc-logger';
export * from './signaling.manager';
export * from './peer-connection.manager';
export * from './media.manager';
export * from './screen-share.manager';
export * from './screen-share.config';
export * from './noise-reduction.manager';

View File

@@ -1,80 +0,0 @@
export interface DesktopSource {
id: string;
name: string;
thumbnail: string;
}
export interface ElectronDesktopSourceSelection {
includeSystemAudio: boolean;
source: DesktopSource;
}
export interface ElectronDesktopCaptureResult {
includeSystemAudio: boolean;
stream: MediaStream;
}
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
active: boolean;
monitorCaptureSupported: boolean;
screenShareSinkName: string;
screenShareMonitorSourceName: string;
voiceSinkName: string;
reason?: string;
}
export interface LinuxScreenShareMonitorCaptureInfo {
bitsPerSample: number;
captureId: string;
channelCount: number;
sampleRate: number;
sourceName: string;
}
export interface LinuxScreenShareMonitorAudioChunkPayload {
captureId: string;
chunk: Uint8Array;
}
export interface LinuxScreenShareMonitorAudioEndedPayload {
captureId: string;
reason?: string;
}
export interface ScreenShareElectronApi {
getSources?: () => Promise<DesktopSource[]>;
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
}
export type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
maxWidth: number;
maxHeight: number;
maxFrameRate: number;
};
};
export type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
};
};
export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
video: ElectronDesktopVideoConstraint;
audio?: false | ElectronDesktopAudioConstraint;
}
export type ScreenShareWindow = Window & {
electronAPI?: ScreenShareElectronApi;
};

View File

@@ -0,0 +1,224 @@
import {
Injectable,
effect,
inject
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { DatabaseService } from '../../../infrastructure/persistence';
import { ROOM_URL_PATTERN } from '../../../core/constants';
import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
import type {
FileAnnouncePayload,
FileCancelPayload,
FileChunkPayload,
FileNotFoundPayload,
FileRequestPayload
} from '../domain/attachment-transfer.models';
import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service';
@Injectable({ providedIn: 'root' })
export class AttachmentManagerService {
get updated() {
return this.runtimeStore.updated;
}
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly router = inject(Router);
private readonly database = inject(DatabaseService);
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly persistence = inject(AttachmentPersistenceService);
private readonly transfer = inject(AttachmentTransferService);
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false;
constructor() {
effect(() => {
if (this.database.isReady() && !this.isDatabaseInitialised) {
this.isDatabaseInitialised = true;
void this.persistence.initFromDatabase();
}
});
this.router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) {
return;
}
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
if (this.watchedRoomId) {
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
this.webrtc.onPeerConnected.subscribe(() => {
if (this.watchedRoomId) {
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
}
getForMessage(messageId: string): Attachment[] {
return this.runtimeStore.getAttachmentsForMessage(messageId);
}
rememberMessageRoom(messageId: string, roomId: string): void {
if (!messageId || !roomId)
return;
this.runtimeStore.rememberMessageRoom(messageId, roomId);
}
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
}
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!roomId || !this.isRoomWatched(roomId))
return;
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
await this.requestAutoDownloadsForMessage(message.id);
}
return;
}
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
}
async deleteForMessage(messageId: string): Promise<void> {
await this.persistence.deleteForMessage(messageId);
}
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
return this.transfer.getAttachmentMetasForMessages(messageIds);
}
registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
for (const attachment of attachments) {
this.queueAutoDownloadsForMessage(messageId, attachment.id);
}
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestFromAnyPeer(messageId, attachment);
}
handleFileNotFound(payload: FileNotFoundPayload): void {
this.transfer.handleFileNotFound(payload);
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestImageFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.transfer.requestFile(messageId, attachment);
}
async publishAttachments(
messageId: string,
files: File[],
uploaderPeerId?: string
): Promise<void> {
await this.transfer.publishAttachments(messageId, files, uploaderPeerId);
}
handleFileAnnounce(payload: FileAnnouncePayload): void {
this.transfer.handleFileAnnounce(payload);
if (payload.messageId && payload.file?.id) {
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
}
}
handleFileChunk(payload: FileChunkPayload): void {
this.transfer.handleFileChunk(payload);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
await this.transfer.handleFileRequest(payload);
}
cancelRequest(messageId: string, attachment: Attachment): void {
this.transfer.cancelRequest(messageId, attachment);
}
handleFileCancel(payload: FileCancelPayload): void {
this.transfer.handleFileCancel(payload);
}
async fulfillRequestWithFile(
messageId: string,
fileId: string,
targetPeerId: string,
file: File
): Promise<void> {
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
}
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
if (!messageId)
return;
const roomId = await this.persistence.resolveMessageRoomId(messageId);
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
return;
}
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
for (const attachment of attachments) {
if (attachmentId && attachment.id !== attachmentId)
continue;
if (!shouldAutoRequestWhenWatched(attachment))
continue;
if (attachment.available)
continue;
if ((attachment.receivedBytes ?? 0) > 0)
continue;
if (this.transfer.hasPendingRequest(messageId, attachment.id))
continue;
this.transfer.requestFromAnyPeer(messageId, attachment);
}
}
private extractWatchedRoomId(url: string): string | null {
const roomMatch = url.match(ROOM_URL_PATTERN);
return roomMatch ? roomMatch[1] : null;
}
private isRoomWatched(roomId: string | null | undefined): boolean {
return !!roomId && roomId === this.watchedRoomId;
}
}

View File

@@ -0,0 +1,264 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs';
import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors';
import { DatabaseService } from '../../../infrastructure/persistence';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' })
export class AttachmentPersistenceService {
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly ngrxStore = inject(Store);
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly database = inject(DatabaseService);
async deleteForMessage(messageId: string): Promise<void> {
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId);
const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId);
const savedPathsToDelete = new Set<string>();
for (const attachment of attachments) {
if (attachment.objectUrl) {
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
}
if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) {
savedPathsToDelete.add(attachment.savedPath);
}
}
this.runtimeStore.deleteAttachmentsForMessage(messageId);
this.runtimeStore.deleteMessageRoom(messageId);
this.runtimeStore.clearMessageScopedState(messageId);
if (hadCachedAttachments) {
this.runtimeStore.touch();
}
if (this.database.isReady()) {
await this.database.deleteAttachmentsForMessage(messageId);
}
for (const diskPath of savedPathsToDelete) {
await this.attachmentStorage.deleteFile(diskPath);
}
}
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
if (!this.database.isReady())
return;
try {
await this.database.saveAttachment({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId,
filePath: attachment.filePath,
savedPath: attachment.savedPath
});
} catch { /* persistence is best-effort */ }
}
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
try {
const roomName = await this.resolveCurrentRoomName();
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
if (!diskPath)
return;
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
} catch { /* disk save is best-effort */ }
}
async initFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
async resolveMessageRoomId(messageId: string): Promise<string | null> {
const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId);
if (cachedRoomId)
return cachedRoomId;
if (!this.database.isReady())
return null;
try {
const message = await this.database.getMessageById(messageId);
if (!message?.roomId)
return null;
this.runtimeStore.rememberMessageRoom(messageId, message.roomId);
return message.roomId;
} catch {
return null;
}
}
async resolveCurrentRoomName(): Promise<string> {
return new Promise<string>((resolve) => {
this.ngrxStore
.select(selectCurrentRoomName)
.pipe(take(1))
.subscribe((name) => resolve(name || ''));
});
}
private async loadFromDatabase(): Promise<void> {
try {
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
const grouped = new Map<string, Attachment[]>();
for (const record of allRecords) {
const attachment: Attachment = { ...record,
available: false };
const bucket = grouped.get(record.messageId) ?? [];
bucket.push(attachment);
grouped.set(record.messageId, bucket);
}
this.runtimeStore.replaceAttachments(grouped);
this.runtimeStore.touch();
} catch { /* load is best-effort */ }
}
private async migrateFromLocalStorage(): Promise<void> {
try {
const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
if (!raw)
return;
const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
for (const meta of legacyRecords) {
const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)];
if (!existing.find((entry) => entry.id === meta.id)) {
const attachment: Attachment = { ...meta,
available: false };
existing.push(attachment);
this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing);
void this.persistAttachmentMeta(attachment);
}
}
localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
this.runtimeStore.touch();
} catch { /* migration is best-effort */ }
}
private async tryLoadSavedFiles(): Promise<void> {
try {
let hasChanges = false;
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) {
if (attachment.available)
continue;
if (attachment.savedPath) {
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
if (savedBase64) {
this.restoreAttachmentFromDisk(attachment, savedBase64);
hasChanges = true;
continue;
}
}
if (attachment.filePath) {
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
if (originalBase64) {
this.restoreAttachmentFromDisk(attachment, originalBase64);
hasChanges = true;
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) {
const response = await fetch(attachment.objectUrl);
void this.saveFileToDisk(attachment, await response.blob());
}
continue;
}
}
}
}
if (hasChanges)
this.runtimeStore.touch();
} catch { /* startup load is best-effort */ }
}
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
const bytes = this.base64ToUint8Array(base64);
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
attachment.objectUrl = URL.createObjectURL(blob);
attachment.available = true;
this.runtimeStore.setOriginalFile(
`${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime })
);
}
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
const retainedSavedPaths = new Set<string>();
for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) {
if (existingMessageId === messageId)
continue;
for (const attachment of attachments) {
if (attachment.savedPath) {
retainedSavedPaths.add(attachment.savedPath);
}
}
}
if (!this.database.isReady()) {
return retainedSavedPaths;
}
const persistedAttachments = await this.database.getAllAttachments();
for (const attachment of persistedAttachments) {
if (attachment.messageId !== messageId && attachment.savedPath) {
retainedSavedPaths.add(attachment.savedPath);
}
}
return retainedSavedPaths;
}
private base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
}

View File

@@ -0,0 +1,160 @@
import { Injectable, signal } from '@angular/core';
import type { Attachment } from '../domain/attachment.models';
@Injectable({ providedIn: 'root' })
export class AttachmentRuntimeStore {
readonly updated = signal<number>(0);
private attachmentsByMessage = new Map<string, Attachment[]>();
private messageRoomIds = new Map<string, string>();
private originalFiles = new Map<string, File>();
private cancelledTransfers = new Set<string>();
private pendingRequests = new Map<string, Set<string>>();
private chunkBuffers = new Map<string, ArrayBuffer[]>();
private chunkCounts = new Map<string, number>();
touch(): void {
this.updated.set(this.updated() + 1);
}
getAttachmentsForMessage(messageId: string): Attachment[] {
return this.attachmentsByMessage.get(messageId) ?? [];
}
setAttachmentsForMessage(messageId: string, attachments: Attachment[]): void {
if (attachments.length === 0) {
this.attachmentsByMessage.delete(messageId);
return;
}
this.attachmentsByMessage.set(messageId, attachments);
}
hasAttachmentsForMessage(messageId: string): boolean {
return this.attachmentsByMessage.has(messageId);
}
deleteAttachmentsForMessage(messageId: string): void {
this.attachmentsByMessage.delete(messageId);
}
replaceAttachments(nextAttachments: Map<string, Attachment[]>): void {
this.attachmentsByMessage = nextAttachments;
}
getAttachmentEntries(): IterableIterator<[string, Attachment[]]> {
return this.attachmentsByMessage.entries();
}
rememberMessageRoom(messageId: string, roomId: string): void {
this.messageRoomIds.set(messageId, roomId);
}
getMessageRoomId(messageId: string): string | undefined {
return this.messageRoomIds.get(messageId);
}
deleteMessageRoom(messageId: string): void {
this.messageRoomIds.delete(messageId);
}
setOriginalFile(key: string, file: File): void {
this.originalFiles.set(key, file);
}
getOriginalFile(key: string): File | undefined {
return this.originalFiles.get(key);
}
findOriginalFileByFileId(fileId: string): File | null {
for (const [key, file] of this.originalFiles) {
if (key.endsWith(`:${fileId}`)) {
return file;
}
}
return null;
}
addCancelledTransfer(key: string): void {
this.cancelledTransfers.add(key);
}
hasCancelledTransfer(key: string): boolean {
return this.cancelledTransfers.has(key);
}
setPendingRequestPeers(key: string, peers: Set<string>): void {
this.pendingRequests.set(key, peers);
}
getPendingRequestPeers(key: string): Set<string> | undefined {
return this.pendingRequests.get(key);
}
hasPendingRequest(key: string): boolean {
return this.pendingRequests.has(key);
}
deletePendingRequest(key: string): void {
this.pendingRequests.delete(key);
}
setChunkBuffer(key: string, buffer: ArrayBuffer[]): void {
this.chunkBuffers.set(key, buffer);
}
getChunkBuffer(key: string): ArrayBuffer[] | undefined {
return this.chunkBuffers.get(key);
}
deleteChunkBuffer(key: string): void {
this.chunkBuffers.delete(key);
}
setChunkCount(key: string, count: number): void {
this.chunkCounts.set(key, count);
}
getChunkCount(key: string): number | undefined {
return this.chunkCounts.get(key);
}
deleteChunkCount(key: string): void {
this.chunkCounts.delete(key);
}
clearMessageScopedState(messageId: string): void {
const scopedPrefix = `${messageId}:`;
for (const key of Array.from(this.originalFiles.keys())) {
if (key.startsWith(scopedPrefix)) {
this.originalFiles.delete(key);
}
}
for (const key of Array.from(this.pendingRequests.keys())) {
if (key.startsWith(scopedPrefix)) {
this.pendingRequests.delete(key);
}
}
for (const key of Array.from(this.chunkBuffers.keys())) {
if (key.startsWith(scopedPrefix)) {
this.chunkBuffers.delete(key);
}
}
for (const key of Array.from(this.chunkCounts.keys())) {
if (key.startsWith(scopedPrefix)) {
this.chunkCounts.delete(key);
}
}
for (const key of Array.from(this.cancelledTransfers)) {
if (key.startsWith(scopedPrefix)) {
this.cancelledTransfers.delete(key);
}
}
}
}

View File

@@ -0,0 +1,109 @@
import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants';
import { FileChunkEvent } from '../domain/attachment-transfer.models';
@Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentStorage = inject(AttachmentStorageService);
decodeBase64(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
async streamFileToPeer(
targetPeerId: string,
messageId: string,
fileId: string,
file: File,
isCancelled: () => boolean
): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
let offset = 0;
let chunkIndex = 0;
while (offset < file.size) {
if (isCancelled())
break;
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++;
}
}
async streamFileFromDiskToPeer(
targetPeerId: string,
messageId: string,
fileId: string,
diskPath: string,
isCancelled: () => boolean
): Promise<void> {
const base64Full = await this.attachmentStorage.readFile(diskPath);
if (!base64Full)
return;
const fileBytes = this.decodeBase64(base64Full);
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (isCancelled())
break;
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES);
const slice = fileBytes.subarray(start, end);
const sliceBuffer = (slice.buffer as ArrayBuffer).slice(
slice.byteOffset,
slice.byteOffset + slice.byteLength
);
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64Chunk
};
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
}
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
}

View File

@@ -0,0 +1,566 @@
import { Injectable, inject } from '@angular/core';
import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE,
FILE_NOT_FOUND_REQUEST_ERROR,
NO_CONNECTED_PEERS_REQUEST_ERROR
} from '../domain/attachment-transfer.constants';
import {
type FileAnnounceEvent,
type FileAnnouncePayload,
type FileCancelEvent,
type FileCancelPayload,
type FileChunkPayload,
type FileNotFoundEvent,
type FileNotFoundPayload,
type FileRequestEvent,
type FileRequestPayload,
type LocalFileWithPath
} from '../domain/attachment-transfer.models';
import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
@Injectable({ providedIn: 'root' })
export class AttachmentTransferService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService);
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {};
for (const messageId of messageIds) {
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
if (attachments.length > 0) {
result[messageId] = attachments.map((attachment) => ({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId,
filePath: undefined,
savedPath: undefined
}));
}
}
return result;
}
registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
if (messageRoomIds) {
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
this.runtimeStore.rememberMessageRoom(messageId, roomId);
}
}
const newAttachments: Attachment[] = [];
for (const [messageId, metas] of Object.entries(attachmentMap)) {
const existing = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
for (const meta of metas) {
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
if (!alreadyKnown) {
const attachment: Attachment = { ...meta,
available: false,
receivedBytes: 0 };
existing.push(attachment);
newAttachments.push(attachment);
}
}
this.runtimeStore.setAttachmentsForMessage(messageId, existing);
}
if (newAttachments.length > 0) {
this.runtimeStore.touch();
for (const attachment of newAttachments) {
void this.persistence.persistAttachmentMeta(attachment);
}
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
const clearedRequestError = this.clearAttachmentRequestError(attachment);
const connectedPeers = this.webrtc.getConnectedPeers();
if (connectedPeers.length === 0) {
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
this.runtimeStore.touch();
console.warn('[Attachments] No connected peers to request file from');
return;
}
if (clearedRequestError)
this.runtimeStore.touch();
this.runtimeStore.setPendingRequestPeers(
this.buildRequestKey(messageId, attachment.id),
new Set<string>()
);
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
}
handleFileNotFound(payload: FileNotFoundPayload): void {
const { messageId, fileId } = payload;
if (!messageId || !fileId)
return;
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = attachments.find((entry) => entry.id === fileId);
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
if (!didSendRequest && attachment) {
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
this.runtimeStore.touch();
}
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
}
hasPendingRequest(messageId: string, fileId: string): boolean {
return this.runtimeStore.hasPendingRequest(this.buildRequestKey(messageId, fileId));
}
async publishAttachments(
messageId: string,
files: File[],
uploaderPeerId?: string
): Promise<void> {
const attachments: Attachment[] = [];
for (const file of files) {
const fileId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
const attachment: Attachment = {
id: fileId,
messageId,
filename: file.name,
size: file.size,
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
isImage: file.type.startsWith('image/'),
uploaderPeerId,
filePath: (file as LocalFileWithPath).path,
available: false
};
attachments.push(attachment);
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
try {
attachment.objectUrl = URL.createObjectURL(file);
attachment.available = true;
} catch { /* non-critical */ }
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
}
const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce',
messageId,
file: {
id: fileId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId
}
};
this.webrtc.broadcastMessage(fileAnnounceEvent);
}
const existingList = this.runtimeStore.getAttachmentsForMessage(messageId);
this.runtimeStore.setAttachmentsForMessage(messageId, [...existingList, ...attachments]);
this.runtimeStore.touch();
for (const attachment of attachments) {
void this.persistence.persistAttachmentMeta(attachment);
}
}
handleFileAnnounce(payload: FileAnnouncePayload): void {
const { messageId, file } = payload;
if (!messageId || !file)
return;
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown)
return;
const attachment: Attachment = {
id: file.id,
messageId,
filename: file.filename,
size: file.size,
mime: file.mime,
isImage: !!file.isImage,
uploaderPeerId: file.uploaderPeerId,
available: false,
receivedBytes: 0
};
list.push(attachment);
this.runtimeStore.setAttachmentsForMessage(messageId, list);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
handleFileChunk(payload: FileChunkPayload): void {
const { messageId, fileId, fromPeerId, index, total, data } = payload;
if (
!messageId || !fileId ||
typeof index !== 'number' ||
typeof total !== 'number' ||
typeof data !== 'string'
) {
return;
}
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = list.find((entry) => entry.id === fileId);
if (!attachment)
return;
const decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId);
this.runtimeStore.deletePendingRequest(requestKey);
this.clearAttachmentRequestError(attachment);
const chunkBuffer = this.getOrCreateChunkBuffer(assemblyKey, total);
if (!chunkBuffer[index]) {
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
}
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
this.runtimeStore.touch();
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId)
return;
const exactKey = `${messageId}:${fileId}`;
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
?? this.runtimeStore.findOriginalFileByFileId(fileId);
if (originalFile) {
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
originalFile,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = list.find((entry) => entry.id === fileId);
const diskPath = attachment
? await this.attachmentStorage.resolveExistingPath(attachment)
: null;
if (diskPath) {
await this.transport.streamFileFromDiskToPeer(
fromPeerId,
messageId,
fileId,
diskPath,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
if (attachment?.isImage) {
const roomName = await this.persistence.resolveCurrentRoomName();
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
attachment.filename,
roomName
);
if (legacyDiskPath) {
await this.transport.streamFileFromDiskToPeer(
fromPeerId,
messageId,
fileId,
legacyDiskPath,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
}
if (attachment?.available && attachment.objectUrl) {
try {
const response = await fetch(attachment.objectUrl);
const blob = await response.blob();
const file = new File([blob], attachment.filename, { type: attachment.mime });
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
file,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
} catch { /* fall through */ }
}
const fileNotFoundEvent: FileNotFoundEvent = {
type: 'file-not-found',
messageId,
fileId
};
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
}
cancelRequest(messageId: string, attachment: Attachment): void {
const targetPeerId = attachment.uploaderPeerId;
if (!targetPeerId)
return;
try {
const assemblyKey = `${messageId}:${attachment.id}`;
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
attachment.receivedBytes = 0;
attachment.speedBps = 0;
attachment.startedAtMs = undefined;
attachment.lastUpdateMs = undefined;
if (attachment.objectUrl) {
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
attachment.objectUrl = undefined;
}
attachment.available = false;
this.runtimeStore.touch();
const fileCancelEvent: FileCancelEvent = {
type: 'file-cancel',
messageId,
fileId: attachment.id
};
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
} catch { /* best-effort */ }
}
handleFileCancel(payload: FileCancelPayload): void {
const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId)
return;
this.runtimeStore.addCancelledTransfer(
this.buildTransferKey(messageId, fileId, fromPeerId)
);
}
async fulfillRequestWithFile(
messageId: string,
fileId: string,
targetPeerId: string,
file: File
): Promise<void> {
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
await this.transport.streamFileToPeer(
targetPeerId,
messageId,
fileId,
file,
() => this.isTransferCancelled(targetPeerId, messageId, fileId)
);
}
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
return `${messageId}:${fileId}:${peerId}`;
}
private buildRequestKey(messageId: string, fileId: string): string {
return `${messageId}:${fileId}`;
}
private clearAttachmentRequestError(attachment: Attachment): boolean {
if (!attachment.requestError)
return false;
attachment.requestError = undefined;
return true;
}
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
return this.runtimeStore.hasCancelledTransfer(
this.buildTransferKey(messageId, fileId, targetPeerId)
);
}
private sendFileRequestToNextPeer(
messageId: string,
fileId: string,
preferredPeerId?: string
): boolean {
const connectedPeers = this.webrtc.getConnectedPeers();
const requestKey = this.buildRequestKey(messageId, fileId);
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
let targetPeerId: string | undefined;
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
targetPeerId = preferredPeerId;
} else {
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
}
if (!targetPeerId) {
this.runtimeStore.deletePendingRequest(requestKey);
return false;
}
triedPeers.add(targetPeerId);
this.runtimeStore.setPendingRequestPeers(requestKey, triedPeers);
const fileRequestEvent: FileRequestEvent = {
type: 'file-request',
messageId,
fileId
};
this.webrtc.sendToPeer(targetPeerId, fileRequestEvent);
return true;
}
private getOrCreateChunkBuffer(assemblyKey: string, total: number): ArrayBuffer[] {
const existingChunkBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
if (existingChunkBuffer) {
return existingChunkBuffer;
}
const createdChunkBuffer = new Array(total);
this.runtimeStore.setChunkBuffer(assemblyKey, createdChunkBuffer);
this.runtimeStore.setChunkCount(assemblyKey, 0);
return createdChunkBuffer;
}
private updateTransferProgress(
attachment: Attachment,
decodedBytes: Uint8Array,
fromPeerId?: string
): void {
const now = Date.now();
const previousReceived = attachment.receivedBytes ?? 0;
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
if (fromPeerId) {
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
}
if (!attachment.startedAtMs)
attachment.startedAtMs = now;
if (!attachment.lastUpdateMs)
attachment.lastUpdateMs = now;
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
const previousSpeed = attachment.speedBps ?? instantaneousBps;
attachment.speedBps =
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT * previousSpeed +
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT * instantaneousBps;
attachment.lastUpdateMs = now;
}
private finalizeTransferIfComplete(
attachment: Attachment,
assemblyKey: string,
total: number
): void {
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
if (
!completeBuffer
|| (receivedChunkCount !== total && (attachment.receivedBytes ?? 0) < attachment.size)
|| !completeBuffer.every((part) => part instanceof ArrayBuffer)
) {
return;
}
const blob = new Blob(completeBuffer, { type: attachment.mime });
attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob);
if (shouldPersistDownloadedAttachment(attachment)) {
void this.persistence.saveFileToDisk(attachment, blob);
}
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
}

View File

@@ -0,0 +1,119 @@
import { Injectable, inject } from '@angular/core';
import { AttachmentManagerService } from './attachment-manager.service';
@Injectable({ providedIn: 'root' })
export class AttachmentFacade {
get updated() {
return this.manager.updated;
}
private readonly manager = inject(AttachmentManagerService);
getForMessage(
...args: Parameters<AttachmentManagerService['getForMessage']>
): ReturnType<AttachmentManagerService['getForMessage']> {
return this.manager.getForMessage(...args);
}
rememberMessageRoom(
...args: Parameters<AttachmentManagerService['rememberMessageRoom']>
): ReturnType<AttachmentManagerService['rememberMessageRoom']> {
return this.manager.rememberMessageRoom(...args);
}
queueAutoDownloadsForMessage(
...args: Parameters<AttachmentManagerService['queueAutoDownloadsForMessage']>
): ReturnType<AttachmentManagerService['queueAutoDownloadsForMessage']> {
return this.manager.queueAutoDownloadsForMessage(...args);
}
requestAutoDownloadsForRoom(
...args: Parameters<AttachmentManagerService['requestAutoDownloadsForRoom']>
): ReturnType<AttachmentManagerService['requestAutoDownloadsForRoom']> {
return this.manager.requestAutoDownloadsForRoom(...args);
}
deleteForMessage(
...args: Parameters<AttachmentManagerService['deleteForMessage']>
): ReturnType<AttachmentManagerService['deleteForMessage']> {
return this.manager.deleteForMessage(...args);
}
getAttachmentMetasForMessages(
...args: Parameters<AttachmentManagerService['getAttachmentMetasForMessages']>
): ReturnType<AttachmentManagerService['getAttachmentMetasForMessages']> {
return this.manager.getAttachmentMetasForMessages(...args);
}
registerSyncedAttachments(
...args: Parameters<AttachmentManagerService['registerSyncedAttachments']>
): ReturnType<AttachmentManagerService['registerSyncedAttachments']> {
return this.manager.registerSyncedAttachments(...args);
}
requestFromAnyPeer(
...args: Parameters<AttachmentManagerService['requestFromAnyPeer']>
): ReturnType<AttachmentManagerService['requestFromAnyPeer']> {
return this.manager.requestFromAnyPeer(...args);
}
handleFileNotFound(
...args: Parameters<AttachmentManagerService['handleFileNotFound']>
): ReturnType<AttachmentManagerService['handleFileNotFound']> {
return this.manager.handleFileNotFound(...args);
}
requestImageFromAnyPeer(
...args: Parameters<AttachmentManagerService['requestImageFromAnyPeer']>
): ReturnType<AttachmentManagerService['requestImageFromAnyPeer']> {
return this.manager.requestImageFromAnyPeer(...args);
}
requestFile(
...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> {
return this.manager.requestFile(...args);
}
publishAttachments(
...args: Parameters<AttachmentManagerService['publishAttachments']>
): ReturnType<AttachmentManagerService['publishAttachments']> {
return this.manager.publishAttachments(...args);
}
handleFileAnnounce(
...args: Parameters<AttachmentManagerService['handleFileAnnounce']>
): ReturnType<AttachmentManagerService['handleFileAnnounce']> {
return this.manager.handleFileAnnounce(...args);
}
handleFileChunk(
...args: Parameters<AttachmentManagerService['handleFileChunk']>
): ReturnType<AttachmentManagerService['handleFileChunk']> {
return this.manager.handleFileChunk(...args);
}
handleFileRequest(
...args: Parameters<AttachmentManagerService['handleFileRequest']>
): ReturnType<AttachmentManagerService['handleFileRequest']> {
return this.manager.handleFileRequest(...args);
}
cancelRequest(
...args: Parameters<AttachmentManagerService['cancelRequest']>
): ReturnType<AttachmentManagerService['cancelRequest']> {
return this.manager.cancelRequest(...args);
}
handleFileCancel(
...args: Parameters<AttachmentManagerService['handleFileCancel']>
): ReturnType<AttachmentManagerService['handleFileCancel']> {
return this.manager.handleFileCancel(...args);
}
fulfillRequestWithFile(
...args: Parameters<AttachmentManagerService['fulfillRequestWithFile']>
): ReturnType<AttachmentManagerService['fulfillRequestWithFile']> {
return this.manager.fulfillRequestWithFile(...args);
}
}

View File

@@ -0,0 +1,21 @@
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
/**
* EWMA smoothing weight for the previous speed estimate.
* The complementary weight is applied to the latest sample.
*/
export const ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT = 0.7;
export const ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT = 1 - ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT;
/** Fallback MIME type when none is provided by the sender. */
export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream';
/** localStorage key used by the legacy attachment store during migration. */
export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments';
/** User-facing error when no peers are available for a request. */
export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
/** User-facing error when connected peers cannot provide a requested file. */
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';

View File

@@ -0,0 +1,56 @@
import type { ChatAttachmentAnnouncement, ChatEvent } from '../../../core/models/index';
export type FileAnnounceEvent = ChatEvent & {
type: 'file-announce';
messageId: string;
file: ChatAttachmentAnnouncement;
};
export type FileChunkEvent = ChatEvent & {
type: 'file-chunk';
messageId: string;
fileId: string;
index: number;
total: number;
data: string;
fromPeerId?: string;
};
export type FileRequestEvent = ChatEvent & {
type: 'file-request';
messageId: string;
fileId: string;
fromPeerId?: string;
};
export type FileCancelEvent = ChatEvent & {
type: 'file-cancel';
messageId: string;
fileId: string;
fromPeerId?: string;
};
export type FileNotFoundEvent = ChatEvent & {
type: 'file-not-found';
messageId: string;
fileId: string;
};
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
export interface FileChunkPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
total?: number;
data?: ChatEvent['data'];
}
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
export type LocalFileWithPath = File & {
path?: string;
};

View File

@@ -0,0 +1,2 @@
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB

View File

@@ -0,0 +1,19 @@
import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants';
import type { Attachment } from './attachment.models';
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('image/') ||
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}
export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
return attachment.isImage ||
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
}
export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, 'size' | 'mime'>): boolean {
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}

View File

@@ -0,0 +1,13 @@
import type { ChatAttachmentMeta } from '../../../core/models/index';
export type AttachmentMeta = ChatAttachmentMeta;
export interface Attachment extends AttachmentMeta {
available: boolean;
objectUrl?: string;
receivedBytes?: number;
speedBps?: number;
startedAtMs?: number;
lastUpdateMs?: number;
requestError?: string;
}

View File

@@ -0,0 +1,3 @@
export * from './application/attachment.facade';
export * from './domain/attachment.constants';
export * from './domain/attachment.models';

View File

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

View File

@@ -0,0 +1,127 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../domain/attachment.models';
import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers';
@Injectable({ providedIn: 'root' })
export class AttachmentStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
async resolveExistingPath(
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
): Promise<string | null> {
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
}
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
const appDataPath = await this.resolveAppDataPath();
if (!appDataPath) {
return null;
}
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
}
async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return null;
}
try {
return await electronApi.readFile(filePath);
} catch {
return null;
}
}
async saveBlob(
attachment: Pick<Attachment, 'filename' | 'mime'>,
blob: Blob,
roomName: string
): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
if (!electronApi || !appDataPath) {
return null;
}
try {
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer();
const diskPath = `${directoryPath}/${attachment.filename}`;
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
return diskPath;
} catch {
return null;
}
}
async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return;
}
try {
await electronApi.deleteFile(filePath);
} catch { /* best-effort cleanup */ }
}
private async resolveAppDataPath(): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
return await electronApi.getAppDataPath();
} catch {
return null;
}
}
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
for (const candidatePath of candidates) {
if (!candidatePath) {
continue;
}
try {
if (await electronApi.fileExists(candidatePath)) {
return candidatePath;
}
} catch { /* keep trying remaining candidates */ }
}
return null;
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
}

View File

@@ -2,7 +2,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service'; import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory';
/** /**
* Response returned by the authentication endpoints (login / register). * Response returned by the authentication endpoints (login / register).
@@ -20,14 +20,14 @@ export interface LoginResponse {
* Handles user authentication (login and registration) against a * Handles user authentication (login and registration) against a
* configurable back-end server. * configurable back-end server.
* *
* The target server is resolved via {@link ServerDirectoryService}: the * The target server is resolved via {@link ServerDirectoryFacade}: the
* caller may pass an explicit `serverId`, otherwise the currently active * caller may pass an explicit `serverId`, otherwise the currently active
* server endpoint is used. * server endpoint is used.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryService); private readonly serverDirectory = inject(ServerDirectoryFacade);
/** /**
* Resolve the API base URL for the given server. * Resolve the API base URL for the given server.

View File

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

View File

@@ -13,7 +13,7 @@ import {
throwError throwError
} from 'rxjs'; } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryService } from './server-directory.service'; import { ServerDirectoryFacade } from '../../server-directory';
export interface KlipyGif { export interface KlipyGif {
id: string; id: string;
@@ -41,7 +41,7 @@ const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class KlipyService { export class KlipyService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryService); private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly availabilityState = signal({ private readonly availabilityState = signal({
enabled: false, enabled: false,
loading: true loading: true

View File

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

View File

@@ -3,8 +3,8 @@ import {
computed, computed,
signal signal
} from '@angular/core'; } from '@angular/core';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from './voice-settings.storage'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session';
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc/webrtc.constants'; import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config';
export type ScreenShareSourceKind = 'screen' | 'window'; export type ScreenShareSourceKind = 'screen' | 'window';

View File

@@ -0,0 +1,31 @@
import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareStartOptions } from '../domain/screen-share.config';
@Injectable({ providedIn: 'root' })
export class ScreenShareFacade {
readonly isScreenSharing = inject(RealtimeSessionFacade).isScreenSharing;
readonly screenStream = inject(RealtimeSessionFacade).screenStream;
readonly isScreenShareRemotePlaybackSuppressed = inject(RealtimeSessionFacade).isScreenShareRemotePlaybackSuppressed;
readonly forceDefaultRemotePlaybackOutput = inject(RealtimeSessionFacade).forceDefaultRemotePlaybackOutput;
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
private readonly realtime = inject(RealtimeSessionFacade);
getRemoteScreenShareStream(peerId: string): MediaStream | null {
return this.realtime.getRemoteScreenShareStream(peerId);
}
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
return await this.realtime.startScreenShare(options);
}
stopScreenShare(): void {
this.realtime.stopScreenShare();
}
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
this.realtime.syncRemoteScreenShareRequests(peerIds, enabled);
}
}

View File

@@ -17,6 +17,8 @@ export interface ScreenShareQualityPreset {
scaleResolutionDownBy?: number; scaleResolutionDownBy?: number;
} }
export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen';
export const DEFAULT_SCREEN_SHARE_QUALITY: ScreenShareQuality = 'balanced'; export const DEFAULT_SCREEN_SHARE_QUALITY: ScreenShareQuality = 'balanced';
export const DEFAULT_SCREEN_SHARE_START_OPTIONS: ScreenShareStartOptions = { export const DEFAULT_SCREEN_SHARE_START_OPTIONS: ScreenShareStartOptions = {

View File

@@ -0,0 +1,3 @@
export * from './application/screen-share.facade';
export * from './application/screen-share-source-picker.service';
export * from './domain/screen-share.config';

View File

@@ -0,0 +1,259 @@
import {
Injectable,
inject,
type Signal
} from '@angular/core';
import { Observable } from 'rxjs';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
import { ServerInfo, User } from '../../../core/models';
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
import type {
BanServerMemberRequest,
CreateServerInviteRequest,
KickServerMemberRequest,
ServerEndpoint,
ServerEndpointVersions,
ServerInviteInfo,
ServerJoinAccessRequest,
ServerJoinAccessResponse,
ServerSourceSelector,
UnbanServerMemberRequest
} from '../domain/server-directory.models';
import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service';
import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
export { CLIENT_UPDATE_REQUIRED_MESSAGE };
@Injectable({ providedIn: 'root' })
export class ServerDirectoryFacade {
readonly servers: Signal<ServerEndpoint[]>;
readonly activeServers: Signal<ServerEndpoint[]>;
readonly hasMissingDefaultServers: Signal<boolean>;
readonly activeServer: Signal<ServerEndpoint | null>;
private readonly endpointState = inject(ServerEndpointStateService);
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
private readonly endpointHealth = inject(ServerEndpointHealthService);
private readonly api = inject(ServerDirectoryApiService);
private shouldSearchAllServers = true;
constructor() {
this.servers = this.endpointState.servers;
this.activeServers = this.endpointState.activeServers;
this.hasMissingDefaultServers = this.endpointState.hasMissingDefaultServers;
this.activeServer = this.endpointState.activeServer;
this.loadConnectionSettings();
void this.testAllServers();
}
addServer(server: { name: string; url: string }): ServerEndpoint {
return this.endpointState.addServer(server);
}
ensureServerEndpoint(
server: { name: string; url: string },
options?: { setActive?: boolean }
): ServerEndpoint {
return this.endpointState.ensureServerEndpoint(server, options);
}
findServerByUrl(url: string): ServerEndpoint | undefined {
return this.endpointState.findServerByUrl(url);
}
removeServer(endpointId: string): void {
this.endpointState.removeServer(endpointId);
}
restoreDefaultServers(): ServerEndpoint[] {
return this.endpointState.restoreDefaultServers();
}
setActiveServer(endpointId: string): void {
this.endpointState.setActiveServer(endpointId);
}
deactivateServer(endpointId: string): void {
this.endpointState.deactivateServer(endpointId);
}
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
): void {
this.endpointState.updateServerStatus(endpointId, status, latency, versions);
}
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
const endpoint = this.api.resolveEndpoint(selector);
if (!endpoint || endpoint.status === 'incompatible') {
return false;
}
const clientVersion = await this.endpointCompatibility.getClientVersion();
if (!clientVersion) {
return true;
}
await this.testServer(endpoint.id);
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
}
setSearchAllServers(enabled: boolean): void {
this.shouldSearchAllServers = enabled;
}
async testServer(endpointId: string): Promise<boolean> {
const endpoint = this.servers().find((entry) => entry.id === endpointId);
if (!endpoint) {
return false;
}
this.updateServerStatus(endpointId, 'checking');
const clientVersion = await this.endpointCompatibility.getClientVersion();
const healthResult = await this.endpointHealth.probeEndpoint(endpoint, clientVersion);
this.updateServerStatus(
endpointId,
healthResult.status,
healthResult.latency,
healthResult.versions
);
return healthResult.status === 'online';
}
async testAllServers(): Promise<void> {
await Promise.all(this.servers().map((endpoint) => this.testServer(endpoint.id)));
}
getApiBaseUrl(selector?: ServerSourceSelector): string {
return this.api.getApiBaseUrl(selector);
}
getWebSocketUrl(selector?: ServerSourceSelector): string {
return this.api.getWebSocketUrl(selector);
}
searchServers(query: string): Observable<ServerInfo[]> {
return this.api.searchServers(query, this.shouldSearchAllServers);
}
getServers(): Observable<ServerInfo[]> {
return this.api.getServers(this.shouldSearchAllServers);
}
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.api.getServer(serverId, selector);
}
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.api.registerServer(server, selector);
}
updateServer(
serverId: string,
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.api.updateServer(serverId, updates, selector);
}
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.api.unregisterServer(serverId, selector);
}
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.api.getServerUsers(serverId, selector);
}
requestJoin(
request: ServerJoinAccessRequest,
selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.api.requestJoin(request, selector);
}
createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.api.createInvite(serverId, request, selector);
}
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.api.getInvite(inviteId, selector);
}
kickServerMember(
serverId: string,
request: KickServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.kickServerMember(serverId, request, selector);
}
banServerMember(
serverId: string,
request: BanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.banServerMember(serverId, request, selector);
}
unbanServerMember(
serverId: string,
request: UnbanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.unbanServerMember(serverId, request, selector);
}
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.api.notifyLeave(serverId, userId, selector);
}
updateUserCount(serverId: string, count: number): Observable<void> {
return this.api.updateUserCount(serverId, count);
}
sendHeartbeat(serverId: string): Observable<void> {
return this.api.sendHeartbeat(serverId);
}
private loadConnectionSettings(): void {
const stored = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (!stored) {
this.shouldSearchAllServers = true;
return;
}
try {
const parsed = JSON.parse(stored) as { searchAllServers?: boolean };
this.shouldSearchAllServers = parsed.searchAllServers ?? true;
} catch {
this.shouldSearchAllServers = true;
}
}
}

View File

@@ -0,0 +1,315 @@
import {
Injectable,
computed,
inject,
signal,
type Signal
} from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
import { environment } from '../../../../environments/environment';
import {
buildDefaultEndpointTemplates,
buildDefaultServerDefinitions,
ensureAnyActiveEndpoint,
ensureCompatibleActiveEndpoint,
findDefaultEndpointKeyByUrl,
hasEndpointForDefault,
matchDefaultEndpointTemplate,
sanitiseServerBaseUrl
} from '../domain/server-endpoint-defaults';
import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service';
import type {
ConfiguredDefaultServerDefinition,
DefaultEndpointTemplate,
ServerEndpoint,
ServerEndpointVersions
} from '../domain/server-directory.models';
function resolveDefaultHttpProtocol(): 'http' | 'https' {
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
? 'https'
: 'http';
}
@Injectable({ providedIn: 'root' })
export class ServerEndpointStateService {
readonly servers: Signal<ServerEndpoint[]>;
readonly activeServers: Signal<ServerEndpoint[]>;
readonly hasMissingDefaultServers: Signal<boolean>;
readonly activeServer: Signal<ServerEndpoint | null>;
private readonly storage = inject(ServerEndpointStorageService);
private readonly _servers = signal<ServerEndpoint[]>([]);
private readonly defaultEndpoints: DefaultEndpointTemplate[];
private readonly primaryDefaultServerUrl: string;
constructor() {
const defaultServerDefinitions = buildDefaultServerDefinitions(
Array.isArray(environment.defaultServers)
? environment.defaultServers as ConfiguredDefaultServerDefinition[]
: [],
environment.defaultServerUrl,
resolveDefaultHttpProtocol()
);
this.defaultEndpoints = buildDefaultEndpointTemplates(defaultServerDefinitions);
this.primaryDefaultServerUrl = this.defaultEndpoints[0]?.url ?? 'http://localhost:3001';
this.servers = computed(() => this._servers());
this.activeServers = computed(() =>
this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible')
);
this.hasMissingDefaultServers = computed(() =>
this.defaultEndpoints.some((endpoint) => !hasEndpointForDefault(this._servers(), endpoint))
);
this.activeServer = computed(() => this.activeServers()[0] ?? null);
this.loadEndpoints();
}
getPrimaryDefaultServerUrl(): string {
return this.primaryDefaultServerUrl;
}
sanitiseUrl(rawUrl: string): string {
return sanitiseServerBaseUrl(rawUrl);
}
addServer(server: { name: string; url: string }): ServerEndpoint {
const newEndpoint: ServerEndpoint = {
id: uuidv4(),
name: server.name,
url: this.sanitiseUrl(server.url),
isActive: true,
isDefault: false,
status: 'unknown'
};
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
this.saveEndpoints();
return newEndpoint;
}
ensureServerEndpoint(
server: { name: string; url: string },
options?: { setActive?: boolean }
): ServerEndpoint {
const existing = this.findServerByUrl(server.url);
if (existing) {
if (options?.setActive) {
this.setActiveServer(existing.id);
}
return existing;
}
const created = this.addServer(server);
if (options?.setActive) {
this.setActiveServer(created.id);
}
return created;
}
findServerByUrl(url: string): ServerEndpoint | undefined {
const sanitisedUrl = this.sanitiseUrl(url);
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
}
removeServer(endpointId: string): void {
const endpoints = this._servers();
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (!target || endpoints.length <= 1) {
return;
}
if (target.isDefault) {
this.markDefaultEndpointRemoved(target);
}
const updatedEndpoints = ensureAnyActiveEndpoint(
endpoints.filter((endpoint) => endpoint.id !== endpointId)
);
this._servers.set(updatedEndpoints);
this.saveEndpoints();
}
restoreDefaultServers(): ServerEndpoint[] {
const restoredEndpoints = this.defaultEndpoints
.filter((defaultEndpoint) => !hasEndpointForDefault(this._servers(), defaultEndpoint))
.map((defaultEndpoint) => ({
...defaultEndpoint,
id: uuidv4(),
isActive: true
}));
if (restoredEndpoints.length === 0) {
this.storage.clearRemovedDefaultEndpointKeys();
return [];
}
this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints]));
this.storage.clearRemovedDefaultEndpointKeys();
this.saveEndpoints();
return restoredEndpoints;
}
setActiveServer(endpointId: string): void {
this._servers.update((endpoints) => {
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (!target || target.status === 'incompatible') {
return endpoints;
}
return endpoints.map((endpoint) =>
endpoint.id === endpointId
? { ...endpoint, isActive: true }
: endpoint
);
});
this.saveEndpoints();
}
deactivateServer(endpointId: string): void {
if (this.activeServers().length <= 1) {
return;
}
this._servers.update((endpoints) =>
endpoints.map((endpoint) =>
endpoint.id === endpointId
? { ...endpoint, isActive: false }
: endpoint
)
);
this.saveEndpoints();
}
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
): void {
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
if (endpoint.id !== endpointId) {
return endpoint;
}
return {
...endpoint,
status,
latency,
isActive: status === 'incompatible' ? false : endpoint.isActive,
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
};
})));
this.saveEndpoints();
}
private loadEndpoints(): void {
const storedEndpoints = this.storage.loadEndpoints();
if (!storedEndpoints) {
this.initialiseDefaultEndpoints();
return;
}
this._servers.set(this.reconcileStoredEndpoints(storedEndpoints));
this.saveEndpoints();
}
private initialiseDefaultEndpoints(): void {
this._servers.set(this.defaultEndpoints.map((endpoint) => ({
...endpoint,
id: uuidv4()
})));
this.saveEndpoints();
}
private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] {
const reconciled: ServerEndpoint[] = [];
const claimedDefaultKeys = new Set<string>();
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
for (const endpoint of storedEndpoints) {
if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') {
continue;
}
const sanitisedUrl = this.sanitiseUrl(endpoint.url);
const matchedDefault = matchDefaultEndpointTemplate(
this.defaultEndpoints,
endpoint,
sanitisedUrl,
claimedDefaultKeys
);
if (matchedDefault) {
claimedDefaultKeys.add(matchedDefault.defaultKey);
reconciled.push({
...endpoint,
name: matchedDefault.name,
url: matchedDefault.url,
isDefault: true,
defaultKey: matchedDefault.defaultKey,
status: endpoint.status ?? 'unknown'
});
continue;
}
reconciled.push({
...endpoint,
url: sanitisedUrl,
status: endpoint.status ?? 'unknown'
});
}
for (const defaultEndpoint of this.defaultEndpoints) {
if (
!claimedDefaultKeys.has(defaultEndpoint.defaultKey)
&& !removedDefaultKeys.has(defaultEndpoint.defaultKey)
&& !hasEndpointForDefault(reconciled, defaultEndpoint)
) {
reconciled.push({
...defaultEndpoint,
id: uuidv4(),
isActive: defaultEndpoint.isActive
});
}
}
return ensureAnyActiveEndpoint(reconciled);
}
private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
removedDefaultKeys.add(defaultKey);
this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys);
}
private saveEndpoints(): void {
this.storage.saveEndpoints(this._servers());
}
}

View File

@@ -0,0 +1 @@
export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users';

View File

@@ -0,0 +1,115 @@
import type { ServerInfo } from '../../../core/models';
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
export interface ConfiguredDefaultServerDefinition {
key?: string;
name?: string;
url?: string;
}
export interface DefaultServerDefinition {
key: string;
name: string;
url: string;
}
export interface ServerEndpointVersions {
serverVersion?: string | null;
clientVersion?: string | null;
}
export interface ServerEndpoint {
id: string;
name: string;
url: string;
isActive: boolean;
isDefault: boolean;
defaultKey?: string;
status: ServerEndpointStatus;
latency?: number;
serverVersion?: string;
clientVersion?: string;
}
export type DefaultEndpointTemplate = Omit<ServerEndpoint, 'id' | 'defaultKey'> & {
defaultKey: string;
};
export interface ServerSourceSelector {
sourceId?: string;
sourceUrl?: string;
}
export interface ServerJoinAccessRequest {
roomId: string;
userId: string;
userPublicKey: string;
displayName: string;
password?: string;
inviteId?: string;
}
export interface ServerJoinAccessResponse {
success: boolean;
signalingUrl: string;
joinedBefore: boolean;
via: 'membership' | 'password' | 'invite' | 'public';
server: ServerInfo;
}
export interface CreateServerInviteRequest {
requesterUserId: string;
requesterDisplayName?: string;
requesterRole?: string;
}
export interface ServerInviteInfo {
id: string;
serverId: string;
createdAt: number;
expiresAt: number;
inviteUrl: string;
browserUrl: string;
appUrl: string;
sourceUrl: string;
createdBy?: string;
createdByDisplayName?: string;
isExpired: boolean;
server: ServerInfo;
}
export interface KickServerMemberRequest {
actorUserId: string;
actorRole?: string;
targetUserId: string;
}
export interface BanServerMemberRequest extends KickServerMemberRequest {
banId?: string;
displayName?: string;
reason?: string;
expiresAt?: number;
}
export interface UnbanServerMemberRequest {
actorUserId: string;
actorRole?: string;
banId?: string;
targetUserId?: string;
}
export interface ServerVersionCompatibilityResult {
isCompatible: boolean;
serverVersion: string | null;
}
export interface ServerHealthCheckPayload {
serverVersion?: unknown;
}
export interface ServerEndpointHealthResult {
status: ServerEndpointStatus;
latency?: number;
versions?: ServerEndpointVersions;
}

View File

@@ -0,0 +1,187 @@
import type {
ConfiguredDefaultServerDefinition,
DefaultEndpointTemplate,
DefaultServerDefinition,
ServerEndpoint
} from './server-directory.models';
export function sanitiseServerBaseUrl(rawUrl: string): string {
let cleaned = rawUrl.trim().replace(/\/+$/, '');
if (cleaned.toLowerCase().endsWith('/api')) {
cleaned = cleaned.slice(0, -4);
}
return cleaned;
}
export function normaliseConfiguredServerUrl(
rawUrl: string,
defaultProtocol: 'http' | 'https'
): string {
let cleaned = rawUrl.trim();
if (!cleaned) {
return '';
}
if (cleaned.toLowerCase().startsWith('ws://')) {
cleaned = `http://${cleaned.slice(5)}`;
} else if (cleaned.toLowerCase().startsWith('wss://')) {
cleaned = `https://${cleaned.slice(6)}`;
} else if (cleaned.startsWith('//')) {
cleaned = `${defaultProtocol}:${cleaned}`;
} else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
cleaned = `${defaultProtocol}://${cleaned}`;
}
return sanitiseServerBaseUrl(cleaned);
}
export function buildFallbackDefaultServerUrl(
configuredUrl: string | undefined,
defaultProtocol: 'http' | 'https'
): string {
if (configuredUrl?.trim()) {
return normaliseConfiguredServerUrl(configuredUrl, defaultProtocol);
}
return `${defaultProtocol}://localhost:3001`;
}
export function buildDefaultServerDefinitions(
configuredDefaults: ConfiguredDefaultServerDefinition[] | undefined,
configuredUrl: string | undefined,
defaultProtocol: 'http' | 'https'
): DefaultServerDefinition[] {
const seenKeys = new Set<string>();
const seenUrls = new Set<string>();
const definitions = (configuredDefaults ?? [])
.map((server, index) => {
const key = server.key?.trim() || `default-${index + 1}`;
const url = normaliseConfiguredServerUrl(server.url ?? '', defaultProtocol);
if (!key || !url || seenKeys.has(key) || seenUrls.has(url)) {
return null;
}
seenKeys.add(key);
seenUrls.add(url);
return {
key,
name: server.name?.trim() || (index === 0 ? 'Default Server' : `Default Server ${index + 1}`),
url
} satisfies DefaultServerDefinition;
})
.filter((definition): definition is DefaultServerDefinition => definition !== null);
if (definitions.length > 0) {
return definitions;
}
return [
{
key: 'default',
name: 'Default Server',
url: buildFallbackDefaultServerUrl(configuredUrl, defaultProtocol)
}
];
}
export function buildDefaultEndpointTemplates(
definitions: DefaultServerDefinition[]
): DefaultEndpointTemplate[] {
return definitions.map((definition) => ({
name: definition.name,
url: definition.url,
isActive: true,
isDefault: true,
defaultKey: definition.key,
status: 'unknown'
}));
}
export function hasEndpointForDefault(
endpoints: ServerEndpoint[],
defaultEndpoint: DefaultEndpointTemplate
): boolean {
return endpoints.some((endpoint) =>
endpoint.defaultKey === defaultEndpoint.defaultKey
|| sanitiseServerBaseUrl(endpoint.url) === defaultEndpoint.url
);
}
export function matchDefaultEndpointTemplate(
defaultEndpoints: DefaultEndpointTemplate[],
endpoint: ServerEndpoint,
sanitisedUrl: string,
claimedDefaultKeys: Set<string>
): DefaultEndpointTemplate | null {
if (endpoint.defaultKey) {
return defaultEndpoints.find(
(candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey)
) ?? null;
}
if (!endpoint.isDefault) {
return null;
}
const matchingCurrentDefault = defaultEndpoints.find(
(candidate) => candidate.url === sanitisedUrl && !claimedDefaultKeys.has(candidate.defaultKey)
);
if (matchingCurrentDefault) {
return matchingCurrentDefault;
}
return defaultEndpoints.find(
(candidate) => !claimedDefaultKeys.has(candidate.defaultKey)
) ?? null;
}
export function findDefaultEndpointKeyByUrl(
defaultEndpoints: DefaultEndpointTemplate[],
url: string
): string | null {
const sanitisedUrl = sanitiseServerBaseUrl(url);
return defaultEndpoints.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null;
}
export function ensureAnyActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
return endpoints;
}
const nextEndpoints = [...endpoints];
nextEndpoints[0] = {
...nextEndpoints[0],
isActive: true
};
return nextEndpoints;
}
export function ensureCompatibleActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
return endpoints;
}
const fallbackIndex = endpoints.findIndex((endpoint) => endpoint.status !== 'incompatible');
if (fallbackIndex < 0) {
return endpoints;
}
const nextEndpoints = [...endpoints];
nextEndpoints[fallbackIndex] = {
...nextEndpoints[fallbackIndex],
isActive: true
};
return nextEndpoints;
}

View File

@@ -0,0 +1,3 @@
export * from './application/server-directory.facade';
export * from './domain/server-directory.constants';
export * from './domain/server-directory.models';

View File

@@ -0,0 +1,404 @@
/* eslint-disable @typescript-eslint/no-invalid-void-type */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
Observable,
forkJoin,
of,
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerInfo, User } from '../../../core/models';
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
import type {
BanServerMemberRequest,
CreateServerInviteRequest,
KickServerMemberRequest,
ServerEndpoint,
ServerInviteInfo,
ServerJoinAccessRequest,
ServerJoinAccessResponse,
ServerSourceSelector,
UnbanServerMemberRequest
} from '../domain/server-directory.models';
@Injectable({ providedIn: 'root' })
export class ServerDirectoryApiService {
private readonly http = inject(HttpClient);
private readonly endpointState = inject(ServerEndpointStateService);
getApiBaseUrl(selector?: ServerSourceSelector): string {
return `${this.resolveBaseServerUrl(selector)}/api`;
}
getWebSocketUrl(selector?: ServerSourceSelector): string {
return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
}
resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
if (selector?.sourceId) {
return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
}
if (selector?.sourceUrl) {
return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null;
}
return this.endpointState.activeServer()
?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible')
?? this.endpointState.servers()[0]
?? null;
}
searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
if (shouldSearchAllServers) {
return this.searchAllEndpoints(query);
}
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
}
getServers(shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
if (shouldSearchAllServers) {
return this.getAllServersFromAllEndpoints();
}
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
catchError((error) => {
console.error('Failed to get servers:', error);
return of([]);
})
);
}
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.http
.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => {
console.error('Failed to get server:', error);
return of(null);
})
);
}
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server)
.pipe(
catchError((error) => {
console.error('Failed to register server:', error);
return throwError(() => error);
})
);
}
updateServer(
serverId: string,
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates)
.pipe(
catchError((error) => {
console.error('Failed to update server:', error);
return throwError(() => error);
})
);
}
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
catchError((error) => {
console.error('Failed to unregister server:', error);
return throwError(() => error);
})
);
}
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.http
.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`)
.pipe(
catchError((error) => {
console.error('Failed to get server users:', error);
return of([]);
})
);
}
requestJoin(
request: ServerJoinAccessRequest,
selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.http
.post<ServerJoinAccessResponse>(
`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`,
request
)
.pipe(
catchError((error) => {
console.error('Failed to send join request:', error);
return throwError(() => error);
})
);
}
createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.http
.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
.pipe(
catchError((error) => {
console.error('Failed to create invite:', error);
return throwError(() => error);
})
);
}
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.http
.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`)
.pipe(
catchError((error) => {
console.error('Failed to get invite:', error);
return throwError(() => error);
})
);
}
kickServerMember(
serverId: string,
request: KickServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
.pipe(
catchError((error) => {
console.error('Failed to kick server member:', error);
return throwError(() => error);
})
);
}
banServerMember(
serverId: string,
request: BanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
.pipe(
catchError((error) => {
console.error('Failed to ban server member:', error);
return throwError(() => error);
})
);
}
unbanServerMember(
serverId: string,
request: UnbanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
.pipe(
catchError((error) => {
console.error('Failed to unban server member:', error);
return throwError(() => error);
})
);
}
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
.pipe(
catchError((error) => {
console.error('Failed to notify leave:', error);
return of(undefined);
})
);
}
updateUserCount(serverId: string, count: number): Observable<void> {
return this.http
.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count })
.pipe(
catchError((error) => {
console.error('Failed to update user count:', error);
return of(undefined);
})
);
}
sendHeartbeat(serverId: string): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
.pipe(
catchError((error) => {
console.error('Failed to send heartbeat:', error);
return of(undefined);
})
);
}
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
if (selector?.sourceUrl) {
return this.endpointState.sanitiseUrl(selector.sourceUrl);
}
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
}
private unwrapServersResponse(
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
): ServerInfo[] {
return Array.isArray(response)
? response
: (response.servers ?? []);
}
private searchSingleEndpoint(
query: string,
apiBaseUrl: string,
source?: ServerEndpoint | null
): Observable<ServerInfo[]> {
const params = new HttpParams().set('q', query);
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
.pipe(
map((response) => this.normalizeServerList(response, source)),
catchError((error) => {
console.error('Failed to search servers:', error);
return of([]);
})
);
}
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter(
(endpoint) => endpoint.status !== 'offline'
);
if (onlineEndpoints.length === 0) {
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
}
return forkJoin(
onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))
).pipe(
map((resultArrays) => resultArrays.flat()),
map((servers) => this.deduplicateById(servers))
);
}
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter(
(endpoint) => endpoint.status !== 'offline'
);
if (onlineEndpoints.length === 0) {
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
catchError(() => of([]))
);
}
return forkJoin(
onlineEndpoints.map((endpoint) =>
this.http
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
.pipe(
map((response) => this.normalizeServerList(response, endpoint)),
catchError(() => of([] as ServerInfo[]))
)
)
).pipe(map((resultArrays) => resultArrays.flat()));
}
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}
private normalizeServerList(
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
source?: ServerEndpoint | null
): ServerInfo[] {
return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source));
}
private normalizeServerInfo(
server: ServerInfo | Record<string, unknown>,
source?: ServerEndpoint | null
): ServerInfo {
const candidate = server as Record<string, unknown>;
const sourceName = this.getStringValue(candidate['sourceName']);
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
return {
id: this.getStringValue(candidate['id']) ?? '',
name: this.getStringValue(candidate['name']) ?? 'Unnamed server',
description: this.getStringValue(candidate['description']),
topic: this.getStringValue(candidate['topic']),
hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API',
ownerId: this.getStringValue(candidate['ownerId']),
ownerName: this.getStringValue(candidate['ownerName']),
ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']),
userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])),
maxUsers: this.getNumberValue(candidate['maxUsers']),
hasPassword: this.getBooleanValue(candidate['hasPassword']),
isPrivate: this.getBooleanValue(candidate['isPrivate']),
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
sourceName: sourceName ?? source?.name,
sourceUrl: sourceUrl
? this.endpointState.sanitiseUrl(sourceUrl)
: (source ? this.endpointState.sanitiseUrl(source.url) : undefined)
};
}
private getBooleanValue(value: unknown): boolean {
return typeof value === 'boolean' ? value : value === 1;
}
private getNumberValue(value: unknown, fallback = 0): number {
return typeof value === 'number' ? value : fallback;
}
private getStringValue(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
}

View File

@@ -0,0 +1,3 @@
export const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
export const REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
export const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000;

View File

@@ -0,0 +1,77 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { ServerVersionCompatibilityResult } from '../domain/server-directory.models';
@Injectable({ providedIn: 'root' })
export class ServerEndpointCompatibilityService {
private readonly electronBridge = inject(ElectronBridgeService);
private clientVersionPromise: Promise<string | null> | null = null;
async getClientVersion(): Promise<string | null> {
if (!this.clientVersionPromise) {
this.clientVersionPromise = this.resolveClientVersion();
}
return await this.clientVersionPromise;
}
evaluateServerVersion(
rawServerVersion: unknown,
clientVersion: string | null
): ServerVersionCompatibilityResult {
const serverVersion = normalizeSemanticVersion(rawServerVersion);
return {
isCompatible: !clientVersion || (serverVersion !== null && serverVersion === clientVersion),
serverVersion
};
}
private async resolveClientVersion(): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
const state = await electronApi.getAutoUpdateState();
return normalizeSemanticVersion(state?.currentVersion);
} catch {
return null;
}
}
}
function normalizeSemanticVersion(rawVersion: unknown): string | null {
if (typeof rawVersion !== 'string') {
return null;
}
const trimmed = rawVersion.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i);
if (!match) {
return null;
}
const major = Number.parseInt(match[1], 10);
const minor = Number.parseInt(match[2], 10);
const patch = Number.parseInt(match[3], 10);
if (
Number.isNaN(major)
|| Number.isNaN(minor)
|| Number.isNaN(patch)
) {
return null;
}
return `${major}.${minor}.${patch}`;
}

View File

@@ -0,0 +1,75 @@
import { Injectable, inject } from '@angular/core';
import { SERVER_HEALTH_CHECK_TIMEOUT_MS } from './server-directory.infrastructure.constants';
import type {
ServerEndpoint,
ServerEndpointHealthResult,
ServerHealthCheckPayload
} from '../domain/server-directory.models';
import { ServerEndpointCompatibilityService } from './server-endpoint-compatibility.service';
@Injectable({ providedIn: 'root' })
export class ServerEndpointHealthService {
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
async probeEndpoint(
endpoint: Pick<ServerEndpoint, 'url'>,
clientVersion: string | null
): Promise<ServerEndpointHealthResult> {
const startTime = Date.now();
try {
const response = await fetch(`${endpoint.url}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS)
});
const latency = Date.now() - startTime;
if (response.ok) {
const payload = await response.json() as ServerHealthCheckPayload;
const versionCompatibility = this.endpointCompatibility.evaluateServerVersion(
payload.serverVersion,
clientVersion
);
if (!versionCompatibility.isCompatible) {
return {
status: 'incompatible',
latency,
versions: {
serverVersion: versionCompatibility.serverVersion,
clientVersion
}
};
}
return {
status: 'online',
latency,
versions: {
serverVersion: versionCompatibility.serverVersion,
clientVersion
}
};
}
return { status: 'offline' };
} catch {
try {
const response = await fetch(`${endpoint.url}/api/servers`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS)
});
const latency = Date.now() - startTime;
if (response.ok) {
return {
status: 'online',
latency
};
}
} catch { /* both checks failed */ }
return { status: 'offline' };
}
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, SERVER_ENDPOINTS_STORAGE_KEY } from './server-directory.infrastructure.constants';
import type { ServerEndpoint } from '../domain/server-directory.models';
@Injectable({ providedIn: 'root' })
export class ServerEndpointStorageService {
loadEndpoints(): ServerEndpoint[] | null {
const stored = localStorage.getItem(SERVER_ENDPOINTS_STORAGE_KEY);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored) as unknown;
return Array.isArray(parsed)
? parsed as ServerEndpoint[]
: null;
} catch {
return null;
}
}
saveEndpoints(endpoints: ServerEndpoint[]): void {
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
}
loadRemovedDefaultEndpointKeys(): Set<string> {
const stored = localStorage.getItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
if (!stored) {
return new Set<string>();
}
try {
const parsed = JSON.parse(stored) as unknown;
if (!Array.isArray(parsed)) {
return new Set<string>();
}
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return new Set<string>();
}
}
saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
if (keys.size === 0) {
this.clearRemovedDefaultEndpointKeys();
return;
}
localStorage.setItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([...keys]));
}
clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
}

View File

@@ -25,8 +25,8 @@ import {
Signal Signal
} from '@angular/core'; } from '@angular/core';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { DebuggingService } from './debugging.service'; import { VoiceConnectionFacade } from './voice-connection.facade';
import { WebRTCService } from './webrtc.service'; import { DebuggingService } from '../../../core/services/debugging.service';
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
const SPEAKING_THRESHOLD = 0.015; const SPEAKING_THRESHOLD = 0.015;
@@ -46,7 +46,7 @@ interface TrackedStream {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoiceActivityService implements OnDestroy { export class VoiceActivityService implements OnDestroy {
private readonly webrtc = inject(WebRTCService); private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly debugging = inject(DebuggingService); private readonly debugging = inject(DebuggingService);
private readonly tracked = new Map<string, TrackedStream>(); private readonly tracked = new Map<string, TrackedStream>();
@@ -58,8 +58,8 @@ export class VoiceActivityService implements OnDestroy {
constructor() { constructor() {
this.subs.push( this.subs.push(
this.webrtc.onRemoteStream.subscribe(({ peerId }) => { this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
const voiceStream = this.webrtc.getRemoteVoiceStream(peerId); const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
if (!voiceStream) { if (!voiceStream) {
this.untrackStream(peerId); this.untrackStream(peerId);
@@ -71,7 +71,7 @@ export class VoiceActivityService implements OnDestroy {
); );
this.subs.push( this.subs.push(
this.webrtc.onPeerDisconnected.subscribe((peerId) => { this.voiceConnection.onPeerDisconnected.subscribe((peerId) => {
this.untrackStream(peerId); this.untrackStream(peerId);
}) })
); );

View File

@@ -0,0 +1,94 @@
import { Injectable, inject } from '@angular/core';
import { ChatEvent } from '../../../core/models/index';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { LatencyProfile } from '../domain/voice-connection.models';
@Injectable({ providedIn: 'root' })
export class VoiceConnectionFacade {
readonly isVoiceConnected = inject(RealtimeSessionFacade).isVoiceConnected;
readonly isMuted = inject(RealtimeSessionFacade).isMuted;
readonly isDeafened = inject(RealtimeSessionFacade).isDeafened;
readonly isNoiseReductionEnabled = inject(RealtimeSessionFacade).isNoiseReductionEnabled;
readonly hasConnectionError = inject(RealtimeSessionFacade).hasConnectionError;
readonly connectionErrorMessage = inject(RealtimeSessionFacade).connectionErrorMessage;
readonly shouldShowConnectionError = inject(RealtimeSessionFacade).shouldShowConnectionError;
readonly peerLatencies = inject(RealtimeSessionFacade).peerLatencies;
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
readonly onPeerConnected = inject(RealtimeSessionFacade).onPeerConnected;
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
readonly onVoiceConnected = inject(RealtimeSessionFacade).onVoiceConnected;
private readonly realtime = inject(RealtimeSessionFacade);
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
return await this.realtime.ensureSignalingConnected(timeoutMs);
}
broadcastMessage(event: ChatEvent): void {
this.realtime.broadcastMessage(event);
}
getConnectedPeers(): string[] {
return this.realtime.getConnectedPeers();
}
getRemoteVoiceStream(peerId: string): MediaStream | null {
return this.realtime.getRemoteVoiceStream(peerId);
}
getLocalStream(): MediaStream | null {
return this.realtime.getLocalStream();
}
getRawMicStream(): MediaStream | null {
return this.realtime.getRawMicStream();
}
async enableVoice(): Promise<MediaStream> {
return await this.realtime.enableVoice();
}
disableVoice(): void {
this.realtime.disableVoice();
}
async setLocalStream(stream: MediaStream): Promise<void> {
await this.realtime.setLocalStream(stream);
}
toggleMute(muted?: boolean): void {
this.realtime.toggleMute(muted);
}
toggleDeafen(deafened?: boolean): void {
this.realtime.toggleDeafen(deafened);
}
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
await this.realtime.toggleNoiseReduction(enabled);
}
setOutputVolume(volume: number): void {
this.realtime.setOutputVolume(volume);
}
setInputVolume(volume: number): void {
this.realtime.setInputVolume(volume);
}
async setAudioBitrate(kbps: number): Promise<void> {
await this.realtime.setAudioBitrate(kbps);
}
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
await this.realtime.setLatencyProfile(profile);
}
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
this.realtime.startVoiceHeartbeat(roomId, serverId);
}
stopVoiceHeartbeat(): void {
this.realtime.stopVoiceHeartbeat();
}
}

View File

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

View File

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

View File

@@ -5,32 +5,10 @@ import {
computed, computed,
inject inject
} from '@angular/core'; } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Room } from '../models'; import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { buildVoiceSessionRoom, isViewingVoiceSessionServer } from '../domain/voice-session.logic';
import type { VoiceSessionInfo } from '../domain/voice-session.models';
/**
* Snapshot of an active voice session, retained so that floating
* voice controls can display the connection details when the user
* navigates away from the server view.
*/
export interface VoiceSessionInfo {
/** Unique server identifier. */
serverId: string;
/** Display name of the server. */
serverName: string;
/** Room/channel ID within the server. */
roomId: string;
/** Display name of the room/channel. */
roomName: string;
/** Optional server icon (data-URL or remote URL). */
serverIcon?: string;
/** Optional server description. */
serverDescription?: string;
/** Angular route path to navigate back to the server. */
serverRoute: string;
}
/** /**
* Tracks the user's current voice session across client-side * Tracks the user's current voice session across client-side
@@ -41,8 +19,7 @@ export interface VoiceSessionInfo {
* voice management lives in {@link WebRTCService} and its managers. * voice management lives in {@link WebRTCService} and its managers.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoiceSessionService { export class VoiceSessionFacade {
private readonly router = inject(Router);
private readonly store = inject(Store); private readonly store = inject(Store);
/** Current voice session metadata, or `null` when disconnected. */ /** Current voice session metadata, or `null` when disconnected. */
@@ -103,14 +80,9 @@ export class VoiceSessionService {
* @param currentServerId - ID of the server the user is currently viewing. * @param currentServerId - ID of the server the user is currently viewing.
*/ */
checkCurrentRoute(currentServerId: string | null): void { checkCurrentRoute(currentServerId: string | null): void {
const session = this._voiceSession(); this._isViewingVoiceServer.set(
isViewingVoiceSessionServer(this._voiceSession(), currentServerId)
if (!session) { );
this._isViewingVoiceServer.set(true);
return;
}
this._isViewingVoiceServer.set(currentServerId === session.serverId);
} }
/** /**
@@ -123,21 +95,9 @@ export class VoiceSessionService {
if (!session) if (!session)
return; return;
const room: Room = {
id: session.serverId,
name: session.serverName,
description: session.serverDescription,
hostId: '',
isPrivate: false,
createdAt: 0,
userCount: 0,
maxUsers: 50,
icon: session.serverIcon
};
this.store.dispatch( this.store.dispatch(
RoomsActions.viewServer({ RoomsActions.viewServer({
room room: buildVoiceSessionRoom(session)
}) })
); );

View File

@@ -7,7 +7,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { VoiceSessionService } from './voice-session.service'; import { VoiceSessionFacade } from './voice-session.facade';
export type VoiceWorkspaceMode = 'hidden' | 'expanded' | 'minimized'; export type VoiceWorkspaceMode = 'hidden' | 'expanded' | 'minimized';
@@ -23,7 +23,7 @@ const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoiceWorkspaceService { export class VoiceWorkspaceService {
private readonly voiceSession = inject(VoiceSessionService); private readonly voiceSession = inject(VoiceSessionFacade);
private readonly _mode = signal<VoiceWorkspaceMode>('hidden'); private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
private readonly _focusedStreamId = signal<string | null>(null); private readonly _focusedStreamId = signal<string | null>(null);

View File

@@ -0,0 +1,23 @@
import type { Room } from '../../../core/models';
import type { VoiceSessionInfo } from './voice-session.models';
export function isViewingVoiceSessionServer(
session: VoiceSessionInfo | null,
currentServerId: string | null
): boolean {
return !session || currentServerId === session.serverId;
}
export function buildVoiceSessionRoom(session: VoiceSessionInfo): Room {
return {
id: session.serverId,
name: session.serverName,
description: session.serverDescription,
hostId: '',
isPrivate: false,
createdAt: 0,
userCount: 0,
maxUsers: 50,
icon: session.serverIcon
};
}

View File

@@ -0,0 +1,21 @@
/**
* Snapshot of an active voice session, retained so that floating
* voice controls can display the connection details when the user
* navigates away from the server view.
*/
export interface VoiceSessionInfo {
/** Unique server identifier. */
serverId: string;
/** Display name of the server. */
serverName: string;
/** Room/channel ID within the server. */
roomId: string;
/** Display name of the room/channel. */
roomName: string;
/** Optional server icon (data-URL or remote URL). */
serverIcon?: string;
/** Optional server description. */
serverDescription?: string;
/** Angular route path to navigate back to the server. */
serverRoute: string;
}

View File

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

View File

@@ -1,6 +1,6 @@
import { STORAGE_KEY_VOICE_SETTINGS } from '../constants'; import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
import { LatencyProfile } from './webrtc/webrtc.constants'; import { LatencyProfile } from '../../voice-connection/domain/voice-connection.models';
import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from './webrtc/screen-share.config'; import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from '../../screen-share/domain/screen-share.config';
const LATENCY_PROFILES: LatencyProfile[] = [ const LATENCY_PROFILES: LatencyProfile[] = [
'low', 'low',

View File

@@ -30,7 +30,7 @@ import {
selectOnlineUsers selectOnlineUsers
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { BanEntry, User } from '../../../core/models/index'; import { BanEntry, User } from '../../../core/models/index';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions'; type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
@@ -93,7 +93,7 @@ export class AdminPanelComponent {
adminsManageIcon = false; adminsManageIcon = false;
moderatorsManageIcon = false; moderatorsManageIcon = false;
private webrtc = inject(WebRTCService); private webrtc = inject(RealtimeSessionFacade);
constructor() { constructor() {
// Initialize from current room // Initialize from current room

View File

@@ -11,8 +11,8 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide'; import { lucideLogIn } from '@ng-icons/lucide';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../domains/auth';
import { ServerDirectoryService } from '../../../core/services/server-directory.service'; import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models/index'; import { User } from '../../../core/models/index';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
* Login form allowing existing users to authenticate against a selected server. * Login form allowing existing users to authenticate against a selected server.
*/ */
export class LoginComponent { export class LoginComponent {
serversSvc = inject(ServerDirectoryService); serversSvc = inject(ServerDirectoryFacade);
servers = this.serversSvc.servers; servers = this.serversSvc.servers;
username = ''; username = '';

View File

@@ -11,8 +11,8 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide'; import { lucideUserPlus } from '@ng-icons/lucide';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../domains/auth';
import { ServerDirectoryService } from '../../../core/services/server-directory.service'; import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models/index'; import { User } from '../../../core/models/index';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
* Registration form allowing new users to create an account on a selected server. * Registration form allowing new users to create an account on a selected server.
*/ */
export class RegisterComponent { export class RegisterComponent {
serversSvc = inject(ServerDirectoryService); serversSvc = inject(ServerDirectoryFacade);
servers = this.serversSvc.servers; servers = this.serversSvc.servers;
username = ''; username = '';

View File

@@ -8,8 +8,10 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Attachment, AttachmentService } from '../../../core/services/attachment.service'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { KlipyGif } from '../../../core/services/klipy.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 { MessagesActions } from '../../../store/messages/messages.actions';
import { import {
selectAllMessages, selectAllMessages,
@@ -19,7 +21,6 @@ import {
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models'; import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
@@ -48,9 +49,10 @@ import {
export class ChatMessagesComponent { export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(WebRTCService); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentService); private readonly attachmentsSvc = inject(AttachmentFacade);
readonly allMessages = this.store.selectSignal(selectAllMessages); readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
@@ -252,17 +254,9 @@ export class ChatMessagesComponent {
if (!attachment.available || !attachment.objectUrl) if (!attachment.available || !attachment.objectUrl)
return; return;
const electronWindow = window as Window & { const electronApi = this.electronBridge.getApi();
electronAPI?: {
saveFileAs?: (
defaultFileName: string,
data: string
) => Promise<{ saved: boolean; cancelled: boolean }>;
};
};
const electronApi = electronWindow.electronAPI;
if (electronApi?.saveFileAs) { if (electronApi) {
const blob = await this.getAttachmentBlob(attachment); const blob = await this.getAttachmentBlob(attachment);
if (blob) { if (blob) {

View File

@@ -19,28 +19,14 @@ import {
lucideSend, lucideSend,
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service'; 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 { Message } from '../../../../../core/models';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component'; import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service'; import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models'; import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
interface ClipboardFilePayload {
data: string;
lastModified: number;
mime: string;
name: string;
path?: string;
}
interface ClipboardElectronApi {
readClipboardFiles?: () => Promise<ClipboardFilePayload[]>;
}
type ClipboardWindow = Window & {
electronAPI?: ClipboardElectronApi;
};
type LocalFileWithPath = File & { type LocalFileWithPath = File & {
path?: string; path?: string;
}; };
@@ -87,6 +73,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly klipy = inject(KlipyService); readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService); private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null); readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly toolbarVisible = signal(false); readonly toolbarVisible = signal(false);
@@ -558,9 +545,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
} }
private async readFilesFromElectronClipboard(): Promise<File[]> { private async readFilesFromElectronClipboard(): Promise<File[]> {
const electronApi = (window as ClipboardWindow).electronAPI; const electronApi = this.electronBridge.getApi();
if (!electronApi?.readClipboardFiles) if (!electronApi)
return []; return [];
try { try {

View File

@@ -31,10 +31,10 @@ import remarkParse from 'remark-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { import {
Attachment, Attachment,
AttachmentService, AttachmentFacade,
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../core/services/attachment.service'; } from '../../../../../domains/attachment';
import { KlipyService } from '../../../../../core/services/klipy.service'; import { KlipyService } from '../../../../../domains/chat';
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models'; import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
@@ -126,7 +126,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
export class ChatMessageItemComponent { export class ChatMessageItemComponent {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
private readonly attachmentsSvc = inject(AttachmentService); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());

View File

@@ -12,7 +12,7 @@ import {
output, output,
signal signal
} from '@angular/core'; } from '@angular/core';
import { Attachment } from '../../../../../core/services/attachment.service'; import { Attachment } from '../../../../../domains/attachment';
import { Message } from '../../../../../core/models'; import { Message } from '../../../../../core/models';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,

View File

@@ -10,7 +10,7 @@ import {
lucideDownload, lucideDownload,
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { Attachment } from '../../../../../core/services/attachment.service'; import { Attachment } from '../../../../../domains/attachment';
import { ContextMenuComponent } from '../../../../../shared'; import { ContextMenuComponent } from '../../../../../shared';
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models'; import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models';

View File

@@ -1,4 +1,4 @@
import { Attachment } from '../../../../core/services/attachment.service'; import { Attachment } from '../../../../domains/attachment';
import { Message } from '../../../../core/models'; import { Message } from '../../../../core/models';
export interface ChatMessageComposerSubmitEvent { export interface ChatMessageComposerSubmitEvent {

View File

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

View File

@@ -8,7 +8,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { import {
merge, merge,
@@ -48,7 +48,7 @@ export class TypingIndicatorComponent {
typingOthersCount = signal<number>(0); typingOthersCount = signal<number>(0);
constructor() { constructor() {
const webrtc = inject(WebRTCService); const webrtc = inject(RealtimeSessionFacade);
const destroyRef = inject(DestroyRef); const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe( const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg): msg is TypingSignalingMessage => filter((msg): msg is TypingSignalingMessage =>

View File

@@ -11,9 +11,10 @@ import { Store } from '@ngrx/store';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
import { UsersActions } from '../../store/users/users.actions'; import { UsersActions } from '../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors';
import { ServerDirectoryService, ServerInviteInfo } from '../../core/services/server-directory.service'; import type { ServerInviteInfo } from '../../domains/server-directory';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import { User } from '../../core/models/index'; import { User } from '../../core/models/index';
@Component({ @Component({
@@ -31,7 +32,7 @@ export class InviteComponent implements OnInit {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly serverDirectory = inject(ServerDirectoryService); private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly databaseService = inject(DatabaseService); private readonly databaseService = inject(DatabaseService);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {

View File

@@ -30,7 +30,7 @@ import {
} from '../../../store/rooms/rooms.selectors'; } from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; import { VoiceWorkspaceService } from '../../../domains/voice-session';
@Component({ @Component({
selector: 'app-chat-room', selector: 'app-chat-room',

View File

@@ -34,10 +34,10 @@ import {
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions'; import { MessagesActions } from '../../../store/messages/messages.actions';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { ScreenShareFacade } from '../../../domains/screen-share';
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
import { VoiceActivityService } from '../../../core/services/voice-activity.service'; import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service'; import { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
import { import {
@@ -87,8 +87,10 @@ type TabView = 'channels' | 'users';
}) })
export class RoomsSidePanelComponent { export class RoomsSidePanelComponent {
private store = inject(Store); private store = inject(Store);
private webrtc = inject(WebRTCService); private realtime = inject(RealtimeSessionFacade);
private voiceSessionService = inject(VoiceSessionService); private voiceConnection = inject(VoiceConnectionFacade);
private screenShare = inject(ScreenShareFacade);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService); private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService); private voicePlayback = inject(VoicePlaybackService);
voiceActivity = inject(VoiceActivityService); voiceActivity = inject(VoiceActivityService);
@@ -283,12 +285,12 @@ export class RoomsSidePanelComponent {
this.store.dispatch(MessagesActions.startSync()); this.store.dispatch(MessagesActions.startSync());
const peers = this.webrtc.getConnectedPeers(); const peers = this.realtime.getConnectedPeers();
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id }; const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
peers.forEach((pid) => { peers.forEach((pid) => {
try { try {
this.webrtc.sendToPeer(pid, inventoryRequest); this.realtime.sendToPeer(pid, inventoryRequest);
} catch { } catch {
return; return;
} }
@@ -362,7 +364,7 @@ export class RoomsSidePanelComponent {
if (user) { if (user) {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.webrtc.broadcastMessage({ this.realtime.broadcastMessage({
type: 'role-change', type: 'role-change',
roomId, roomId,
targetUserId: user.id, targetUserId: user.id,
@@ -403,7 +405,7 @@ export class RoomsSidePanelComponent {
return; return;
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.webrtc.isVoiceConnected()) { if (!this.voiceConnection.isVoiceConnected()) {
if (current.id) { if (current.id) {
this.store.dispatch( this.store.dispatch(
UsersActions.updateVoiceState({ UsersActions.updateVoiceState({
@@ -424,7 +426,7 @@ export class RoomsSidePanelComponent {
} }
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId; const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice();
enableVoicePromise enableVoicePromise
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null)) .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
@@ -441,7 +443,7 @@ export class RoomsSidePanelComponent {
private trackCurrentUserMic(): void { private trackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id; const userId = this.currentUser()?.oderId || this.currentUser()?.id;
const micStream = this.webrtc.getRawMicStream(); const micStream = this.voiceConnection.getRawMicStream();
if (userId && micStream) { if (userId && micStream) {
this.voiceActivity.trackLocalMic(userId, micStream); this.voiceActivity.trackLocalMic(userId, micStream);
@@ -475,11 +477,11 @@ export class RoomsSidePanelComponent {
} }
private startVoiceHeartbeat(roomId: string, room: Room): void { private startVoiceHeartbeat(roomId: string, room: Room): void {
this.webrtc.startVoiceHeartbeat(roomId, room.id); this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
} }
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void { private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
this.webrtc.broadcastMessage({ this.voiceConnection.broadcastMessage({
type: 'voice-state', type: 'voice-state',
oderId: current?.oderId || current?.id, oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User', displayName: current?.displayName || 'User',
@@ -514,11 +516,11 @@ export class RoomsSidePanelComponent {
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
return; return;
this.webrtc.stopVoiceHeartbeat(); this.voiceConnection.stopVoiceHeartbeat();
this.untrackCurrentUserMic(); this.untrackCurrentUserMic();
this.webrtc.disableVoice(); this.voiceConnection.disableVoice();
if (current?.id) { if (current?.id) {
this.store.dispatch( this.store.dispatch(
@@ -535,7 +537,7 @@ export class RoomsSidePanelComponent {
); );
} }
this.webrtc.broadcastMessage({ this.voiceConnection.broadcastMessage({
type: 'voice-state', type: 'voice-state',
oderId: current?.oderId || current?.id, oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User', displayName: current?.displayName || 'User',
@@ -573,7 +575,7 @@ export class RoomsSidePanelComponent {
const me = this.currentUser(); const me = this.currentUser();
if (me?.id === userId) { if (me?.id === userId) {
return this.webrtc.isScreenSharing(); return this.screenShare.isScreenSharing();
} }
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId); const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
@@ -590,7 +592,7 @@ export class RoomsSidePanelComponent {
(candidate): candidate is string => !!candidate (candidate): candidate is string => !!candidate
); );
const stream = peerKeys const stream = peerKeys
.map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey)) .map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null; .find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
return !!stream && stream.getVideoTracks().length > 0; return !!stream && stream.getVideoTracks().length > 0;
@@ -636,7 +638,7 @@ export class RoomsSidePanelComponent {
} }
getPeerLatency(user: User): number | null { getPeerLatency(user: User): number | null {
const latencies = this.webrtc.peerLatencies(); const latencies = this.voiceConnection.peerLatencies();
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null; return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
} }

View File

@@ -39,8 +39,8 @@ import {
User User
} from '../../core/models/index'; } from '../../core/models/index';
import { SettingsModalService } from '../../core/services/settings-modal.service'; import { SettingsModalService } from '../../core/services/settings-modal.service';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../infrastructure/persistence';
import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { ServerDirectoryFacade } from '../../domains/server-directory';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors';
import { ConfirmDialogComponent } from '../../shared'; import { ConfirmDialogComponent } from '../../shared';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
@@ -75,7 +75,7 @@ export class ServerSearchComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private settingsModal = inject(SettingsModalService); private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService); private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService); private serverDirectory = inject(ServerDirectoryFacade);
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;

View File

@@ -15,13 +15,13 @@ import { lucidePlus } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { Room, User } from '../../core/models/index'; import { Room, User } from '../../core/models/index';
import { RealtimeSessionFacade } from '../../core/realtime';
import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../infrastructure/persistence';
import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { ServerDirectoryFacade } from '../../domains/server-directory';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import { import {
ConfirmDialogComponent, ConfirmDialogComponent,
@@ -47,10 +47,10 @@ import {
export class ServersRailComponent { export class ServersRailComponent {
private store = inject(Store); private store = inject(Store);
private router = inject(Router); private router = inject(Router);
private voiceSession = inject(VoiceSessionService); private voiceSession = inject(VoiceSessionFacade);
private webrtc = inject(WebRTCService); private webrtc = inject(RealtimeSessionFacade);
private db = inject(DatabaseService); private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService); private serverDirectory = inject(ServerDirectoryFacade);
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);

View File

@@ -14,7 +14,7 @@ import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide'; import { lucideX } from '@ng-icons/lucide';
import { Room, BanEntry } from '../../../../core/models/index'; import { Room, BanEntry } from '../../../../core/models/index';
import { DatabaseService } from '../../../../core/services/database.service'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';

View File

@@ -8,20 +8,8 @@ import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePower } from '@ng-icons/lucide'; import { lucidePower } from '@ng-icons/lucide';
import { PlatformService } from '../../../../core/services/platform.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
interface DesktopSettingsSnapshot {
autoStart: boolean;
}
interface GeneralSettingsElectronApi {
getDesktopSettings?: () => Promise<DesktopSettingsSnapshot>;
setDesktopSettings?: (patch: { autoStart?: boolean }) => Promise<DesktopSettingsSnapshot>;
}
type GeneralSettingsWindow = Window & {
electronAPI?: GeneralSettingsElectronApi;
};
@Component({ @Component({
selector: 'app-general-settings', selector: 'app-general-settings',
@@ -36,6 +24,7 @@ type GeneralSettingsWindow = Window & {
}) })
export class GeneralSettingsComponent { export class GeneralSettingsComponent {
private platform = inject(PlatformService); private platform = inject(PlatformService);
private electronBridge = inject(ElectronBridgeService);
readonly isElectron = this.platform.isElectron; readonly isElectron = this.platform.isElectron;
autoStart = signal(false); autoStart = signal(false);
@@ -50,9 +39,9 @@ export class GeneralSettingsComponent {
async onAutoStartChange(event: Event): Promise<void> { async onAutoStartChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const enabled = !!input.checked; const enabled = !!input.checked;
const api = this.getElectronApi(); const api = this.electronBridge.getApi();
if (!this.isElectron || !api?.setDesktopSettings) { if (!this.isElectron || !api) {
input.checked = this.autoStart(); input.checked = this.autoStart();
return; return;
} }
@@ -71,9 +60,9 @@ export class GeneralSettingsComponent {
} }
private async loadDesktopSettings(): Promise<void> { private async loadDesktopSettings(): Promise<void> {
const api = this.getElectronApi(); const api = this.electronBridge.getApi();
if (!api?.getDesktopSettings) { if (!api) {
return; return;
} }
@@ -83,10 +72,4 @@ export class GeneralSettingsComponent {
this.autoStart.set(snapshot.autoStart); this.autoStart.set(snapshot.autoStart);
} catch {} } catch {}
} }
private getElectronApi(): GeneralSettingsElectronApi | null {
return typeof window !== 'undefined'
? (window as GeneralSettingsWindow).electronAPI ?? null
: null;
}
} }

View File

@@ -16,9 +16,9 @@ import {
RoomMember, RoomMember,
UserRole UserRole
} from '../../../../core/models/index'; } from '../../../../core/models/index';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors'; import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared'; import { UserAvatarComponent } from '../../../../shared';
@@ -46,7 +46,7 @@ interface ServerMemberView extends RoomMember {
}) })
export class MembersSettingsComponent { export class MembersSettingsComponent {
private store = inject(Store); private store = inject(Store);
private webrtcService = inject(WebRTCService); private webrtcService = inject(RealtimeSessionFacade);
/** The currently selected server, passed from the parent. */ /** The currently selected server, passed from the parent. */
server = input<Room | null>(null); server = input<Room | null>(null);

View File

@@ -18,7 +18,7 @@ import {
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../../../core/services/server-directory.service'; import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants'; import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
@Component({ @Component({
@@ -43,7 +43,7 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
templateUrl: './network-settings.component.html' templateUrl: './network-settings.component.html'
}) })
export class NetworkSettingsComponent { export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryService); private serverDirectory = inject(ServerDirectoryFacade);
servers = this.serverDirectory.servers; servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers; activeServers = this.serverDirectory.activeServers;

View File

@@ -25,11 +25,11 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room, UserRole } from '../../../core/models/index'; import { Room, UserRole } from '../../../core/models/index';
import { findRoomMember } from '../../../store/rooms/room-members.helpers'; import { findRoomMember } from '../../../store/rooms/room-members.helpers';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { GeneralSettingsComponent } from './general-settings/general-settings.component'; import { GeneralSettingsComponent } from './general-settings/general-settings.component';
import { NetworkSettingsComponent } from './network-settings/network-settings.component'; import { NetworkSettingsComponent } from './network-settings/network-settings.component';
@@ -77,7 +77,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
export class SettingsModalComponent { export class SettingsModalComponent {
readonly modal = inject(SettingsModalService); readonly modal = inject(SettingsModalService);
private store = inject(Store); private store = inject(Store);
private webrtc = inject(WebRTCService); private webrtc = inject(RealtimeSessionFacade);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES; readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null; private lastRequestedServerId: string | null = null;

View File

@@ -16,34 +16,20 @@ import {
lucideCpu lucideCpu
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { WebRTCService } from '../../../../core/services/webrtc.service'; import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session';
import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service'; import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/services/platform.service'; import { PlatformService } from '../../../../core/platform';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../core/services/voice-settings.storage';
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../core/services/webrtc';
interface AudioDevice { interface AudioDevice {
deviceId: string; deviceId: string;
label: string; label: string;
} }
interface DesktopSettingsSnapshot {
hardwareAcceleration: boolean;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}
interface DesktopSettingsElectronApi {
getDesktopSettings?: () => Promise<DesktopSettingsSnapshot>;
setDesktopSettings?: (patch: { hardwareAcceleration?: boolean }) => Promise<DesktopSettingsSnapshot>;
relaunchApp?: () => Promise<boolean>;
}
type DesktopSettingsWindow = Window & {
electronAPI?: DesktopSettingsElectronApi;
};
@Component({ @Component({
selector: 'app-voice-settings', selector: 'app-voice-settings',
standalone: true, standalone: true,
@@ -64,8 +50,9 @@ type DesktopSettingsWindow = Window & {
templateUrl: './voice-settings.component.html' templateUrl: './voice-settings.component.html'
}) })
export class VoiceSettingsComponent { export class VoiceSettingsComponent {
private webrtcService = inject(WebRTCService); private voiceConnection = inject(VoiceConnectionFacade);
private voicePlayback = inject(VoicePlaybackService); private voicePlayback = inject(VoicePlaybackService);
private electronBridge = inject(ElectronBridgeService);
private platform = inject(PlatformService); private platform = inject(PlatformService);
readonly audioService = inject(NotificationAudioService); readonly audioService = inject(NotificationAudioService);
readonly isElectron = this.platform.isElectron; readonly isElectron = this.platform.isElectron;
@@ -135,13 +122,13 @@ export class VoiceSettingsComponent {
this.screenShareQuality.set(settings.screenShareQuality); this.screenShareQuality.set(settings.screenShareQuality);
this.askScreenShareQuality.set(settings.askScreenShareQuality); this.askScreenShareQuality.set(settings.askScreenShareQuality);
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) { if (this.noiseReduction() !== this.voiceConnection.isNoiseReductionEnabled()) {
this.webrtcService.toggleNoiseReduction(this.noiseReduction()); this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
} }
// Apply persisted volume levels to the live audio pipelines // Apply persisted volume levels to the live audio pipelines
this.webrtcService.setInputVolume(this.inputVolume() / 100); this.voiceConnection.setInputVolume(this.inputVolume() / 100);
this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
} }
@@ -171,7 +158,7 @@ export class VoiceSettingsComponent {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value); this.selectedOutputDevice.set(select.value);
this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
@@ -179,7 +166,7 @@ export class VoiceSettingsComponent {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10)); this.inputVolume.set(parseInt(input.value, 10));
this.webrtcService.setInputVolume(this.inputVolume() / 100); this.voiceConnection.setInputVolume(this.inputVolume() / 100);
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
@@ -187,7 +174,7 @@ export class VoiceSettingsComponent {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.outputVolume.set(parseInt(input.value, 10)); this.outputVolume.set(parseInt(input.value, 10));
this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
@@ -197,7 +184,7 @@ export class VoiceSettingsComponent {
const profile = select.value as 'low' | 'balanced' | 'high'; const profile = select.value as 'low' | 'balanced' | 'high';
this.latencyProfile.set(profile); this.latencyProfile.set(profile);
this.webrtcService.setLatencyProfile(profile); this.voiceConnection.setLatencyProfile(profile);
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
@@ -205,7 +192,7 @@ export class VoiceSettingsComponent {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.audioBitrate.set(parseInt(input.value, 10)); this.audioBitrate.set(parseInt(input.value, 10));
this.webrtcService.setAudioBitrate(this.audioBitrate()); this.voiceConnection.setAudioBitrate(this.audioBitrate());
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
@@ -232,7 +219,7 @@ export class VoiceSettingsComponent {
async onNoiseReductionChange(): Promise<void> { async onNoiseReductionChange(): Promise<void> {
this.noiseReduction.update((currentValue) => !currentValue); this.noiseReduction.update((currentValue) => !currentValue);
await this.webrtcService.toggleNoiseReduction(this.noiseReduction()); await this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
@@ -249,9 +236,9 @@ export class VoiceSettingsComponent {
async onHardwareAccelerationChange(event: Event): Promise<void> { async onHardwareAccelerationChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const enabled = !!input.checked; const enabled = !!input.checked;
const api = this.getElectronApi(); const api = this.electronBridge.getApi();
if (!api?.setDesktopSettings) { if (!api) {
this.hardwareAcceleration.set(enabled); this.hardwareAcceleration.set(enabled);
return; return;
} }
@@ -266,17 +253,17 @@ export class VoiceSettingsComponent {
} }
async restartDesktopApp(): Promise<void> { async restartDesktopApp(): Promise<void> {
const api = this.getElectronApi(); const api = this.electronBridge.getApi();
if (api?.relaunchApp) { if (api) {
await api.relaunchApp(); await api.relaunchApp();
} }
} }
private async loadDesktopSettings(): Promise<void> { private async loadDesktopSettings(): Promise<void> {
const api = this.getElectronApi(); const api = this.electronBridge.getApi();
if (!api?.getDesktopSettings) { if (!api) {
return; return;
} }
@@ -291,10 +278,4 @@ export class VoiceSettingsComponent {
this.hardwareAcceleration.set(snapshot.hardwareAcceleration); this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired); this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
} }
private getElectronApi(): DesktopSettingsElectronApi | null {
return typeof window !== 'undefined'
? (window as DesktopSettingsWindow).electronAPI ?? null
: null;
}
} }

View File

@@ -23,8 +23,8 @@ import {
lucideAudioLines lucideAudioLines
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { ServerDirectoryFacade } from '../../domains/server-directory';
import { WebRTCService } from '../../core/services/webrtc.service'; import { VoiceConnectionFacade } from '../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants'; import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
@@ -56,8 +56,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
* Settings page for managing signaling servers and connection preferences. * Settings page for managing signaling servers and connection preferences.
*/ */
export class SettingsComponent implements OnInit { export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryService); private serverDirectory = inject(ServerDirectoryFacade);
private webrtcService = inject(WebRTCService); private voiceConnection = inject(VoiceConnectionFacade);
private router = inject(Router); private router = inject(Router);
audioService = inject(NotificationAudioService); audioService = inject(NotificationAudioService);
@@ -184,8 +184,8 @@ export class SettingsComponent implements OnInit {
} }
// Sync the live WebRTC state with the persisted preference // Sync the live WebRTC state with the persisted preference
if (this.noiseReduction !== this.webrtcService.isNoiseReductionEnabled()) { if (this.noiseReduction !== this.voiceConnection.isNoiseReductionEnabled()) {
this.webrtcService.toggleNoiseReduction(this.noiseReduction); this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
} }
} }
@@ -217,6 +217,6 @@ export class SettingsComponent implements OnInit {
noiseReduction: this.noiseReduction }) noiseReduction: this.noiseReduction })
); );
await this.webrtcService.toggleNoiseReduction(this.noiseReduction); await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
} }
} }

View File

@@ -26,23 +26,14 @@ import {
} from '../../store/rooms/rooms.selectors'; } from '../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors';
import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { RealtimeSessionFacade } from '../../core/realtime';
import { PlatformService } from '../../core/services/platform.service'; import { ServerDirectoryFacade } from '../../domains/server-directory';
import { PlatformService } from '../../core/platform';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared'; import { LeaveServerDialogComponent } from '../../shared';
import { Room } from '../../core/models/index'; import { Room } from '../../core/models/index';
interface WindowControlsAPI {
minimizeWindow?: () => void;
maximizeWindow?: () => void;
closeWindow?: () => void;
}
type ElectronWindow = Window & {
electronAPI?: WindowControlsAPI;
};
@Component({ @Component({
selector: 'app-title-bar', selector: 'app-title-bar',
standalone: true, standalone: true,
@@ -67,13 +58,14 @@ type ElectronWindow = Window & {
*/ */
export class TitleBarComponent { export class TitleBarComponent {
private store = inject(Store); private store = inject(Store);
private serverDirectory = inject(ServerDirectoryService); private electronBridge = inject(ElectronBridgeService);
private serverDirectory = inject(ServerDirectoryFacade);
private router = inject(Router); private router = inject(Router);
private webrtc = inject(WebRTCService); private webrtc = inject(RealtimeSessionFacade);
private platform = inject(PlatformService); private platform = inject(PlatformService);
private getWindowControlsApi(): WindowControlsAPI | undefined { private getWindowControlsApi() {
return (window as ElectronWindow).electronAPI; return this.electronBridge.getApi();
} }
isElectron = computed(() => this.platform.isElectron); isElectron = computed(() => this.platform.isElectron);

View File

@@ -19,10 +19,10 @@ import {
lucideArrowLeft lucideArrowLeft
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { VoiceSessionFacade } from '../../../domains/voice-session';
import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { VoiceConnectionFacade } from '../../../domains/voice-connection';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share';
import { ScreenShareQuality } from '../../../core/services/webrtc'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared'; import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
@@ -55,10 +55,11 @@ import { VoicePlaybackService } from '../voice-controls/services/voice-playback.
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay. * Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
*/ */
export class FloatingVoiceControlsComponent implements OnInit { export class FloatingVoiceControlsComponent implements OnInit {
private webrtcService = inject(WebRTCService); private readonly webrtcService = inject(VoiceConnectionFacade);
private voiceSessionService = inject(VoiceSessionService); private readonly screenShareService = inject(ScreenShareFacade);
private voicePlayback = inject(VoicePlaybackService); private readonly voiceSessionService = inject(VoiceSessionFacade);
private store = inject(Store); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
@@ -69,7 +70,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
isConnected = computed(() => this.webrtcService.isVoiceConnected()); isConnected = computed(() => this.webrtcService.isVoiceConnected());
isMuted = signal(false); isMuted = signal(false);
isDeafened = signal(false); isDeafened = signal(false);
isScreenSharing = this.webrtcService.isScreenSharing; isScreenSharing = this.screenShareService.isScreenSharing;
includeSystemAudio = signal(false); includeSystemAudio = signal(false);
screenShareQuality = signal<ScreenShareQuality>('balanced'); screenShareQuality = signal<ScreenShareQuality>('balanced');
askScreenShareQuality = signal(true); askScreenShareQuality = signal(true);
@@ -143,7 +144,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
/** Toggle screen sharing on or off. */ /** Toggle screen sharing on or off. */
async toggleScreenShare(): Promise<void> { async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare(); this.screenShareService.stopScreenShare();
} else { } else {
this.syncScreenShareSettings(); this.syncScreenShareSettings();
@@ -186,7 +187,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
// Stop screen sharing if active // Stop screen sharing if active
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare(); this.screenShareService.stopScreenShare();
} }
// Disable voice // Disable voice
@@ -281,7 +282,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> { private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
try { try {
await this.webrtcService.startScreenShare({ await this.screenShareService.startScreenShare({
includeSystemAudio: this.includeSystemAudio(), includeSystemAudio: this.includeSystemAudio(),
quality quality
}); });

View File

@@ -19,7 +19,7 @@ import {
lucideMonitor lucideMonitor
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { ScreenShareFacade } from '../../../domains/screen-share';
import { selectOnlineUsers } from '../../../store/users/users.selectors'; import { selectOnlineUsers } from '../../../store/users/users.selectors';
import { User } from '../../../core/models/index'; import { User } from '../../../core/models/index';
import { DEFAULT_VOLUME } from '../../../core/constants'; import { DEFAULT_VOLUME } from '../../../core/constants';
@@ -46,9 +46,9 @@ import { VoicePlaybackService } from '../voice-controls/services/voice-playback.
export class ScreenShareViewerComponent implements OnDestroy { export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>; @ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
private webrtcService = inject(WebRTCService); private readonly screenShareService = inject(ScreenShareFacade);
private voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private store = inject(Store); private readonly store = inject(Store);
private remoteStreamSub: Subscription | null = null; private remoteStreamSub: Subscription | null = null;
onlineUsers = this.store.selectSignal(selectOnlineUsers); onlineUsers = this.store.selectSignal(selectOnlineUsers);
@@ -69,7 +69,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
if (!userId) if (!userId)
return; return;
const stream = this.webrtcService.getRemoteScreenShareStream(userId); const stream = this.screenShareService.getRemoteScreenShareStream(userId);
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null; const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) { if (stream && stream.getVideoTracks().length > 0) {
@@ -94,7 +94,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
constructor() { constructor() {
// React to screen share stream changes // React to screen share stream changes
effect(() => { effect(() => {
const screenStream = this.webrtcService.screenStream(); const screenStream = this.screenShareService.screenStream();
if (screenStream && this.videoRef) { if (screenStream && this.videoRef) {
// Local share: always mute to avoid audio feedback // Local share: always mute to avoid audio feedback
@@ -129,7 +129,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
} }
// Also check if the stream's video tracks are still available // Also check if the stream's video tracks are still available
const stream = this.webrtcService.getRemoteScreenShareStream(watchingId); const stream = this.screenShareService.getRemoteScreenShareStream(watchingId);
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live'); const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
if (!hasActiveVideo) { if (!hasActiveVideo) {
@@ -141,12 +141,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
// Subscribe to remote streams with video (screen shares) // Subscribe to remote streams with video (screen shares)
// NOTE: We no longer auto-display remote streams. Users must click "Live" to view. // NOTE: We no longer auto-display remote streams. Users must click "Live" to view.
// This subscription is kept for potential future use (e.g., tracking available streams) // This subscription is kept for potential future use (e.g., tracking available streams)
this.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId }) => { this.remoteStreamSub = this.screenShareService.onRemoteStream.subscribe(({ peerId }) => {
if (peerId !== this.watchingUserId() || this.isLocalShare()) { if (peerId !== this.watchingUserId() || this.isLocalShare()) {
return; return;
} }
const stream = this.webrtcService.getRemoteScreenShareStream(peerId); const stream = this.screenShareService.getRemoteScreenShareStream(peerId);
const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false; const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false;
if (!hasActiveVideo) { if (!hasActiveVideo) {
@@ -202,7 +202,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
/** Stop the local screen share and reset viewer state. */ /** Stop the local screen share and reset viewer state. */
stopSharing(): void { stopSharing(): void {
this.webrtcService.stopScreenShare(); this.screenShareService.stopScreenShare();
this.activeScreenSharer.set(null); this.activeScreenSharer.set(null);
this.hasStream.set(false); this.hasStream.set(false);
this.isLocalShare.set(false); this.isLocalShare.set(false);

View File

@@ -30,11 +30,19 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { User } from '../../../core/models'; import { User } from '../../../core/models';
import { VoiceSessionService } from '../../../core/services/voice-session.service'; import {
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; loadVoiceSettingsFromStorage,
import { VoiceWorkspacePosition, VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; saveVoiceSettingsToStorage,
import { ScreenShareQuality, ScreenShareStartOptions } from '../../../core/services/webrtc'; VoiceSessionFacade,
import { WebRTCService } from '../../../core/services/webrtc.service'; 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 { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
@@ -79,10 +87,11 @@ export class ScreenShareWorkspaceComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef); private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(WebRTCService); private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly screenSharePlayback = inject(ScreenSharePlaybackService); private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
private readonly voiceSession = inject(VoiceSessionService); private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceWorkspace = inject(VoiceWorkspaceService); private readonly voiceWorkspace = inject(VoiceWorkspaceService);
private readonly remoteStreamRevision = signal(0); private readonly remoteStreamRevision = signal(0);
@@ -116,7 +125,7 @@ export class ScreenShareWorkspaceComponent {
readonly isConnected = computed(() => this.webrtc.isVoiceConnected()); readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
readonly isMuted = computed(() => this.webrtc.isMuted()); readonly isMuted = computed(() => this.webrtc.isMuted());
readonly isDeafened = computed(() => this.webrtc.isDeafened()); readonly isDeafened = computed(() => this.webrtc.isDeafened());
readonly isScreenSharing = computed(() => this.webrtc.isScreenSharing()); readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
readonly includeSystemAudio = signal(false); readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced'); readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
@@ -167,7 +176,7 @@ export class ScreenShareWorkspaceComponent {
} }
const shares: ScreenShareWorkspaceStreamItem[] = []; const shares: ScreenShareWorkspaceStreamItem[] = [];
const localStream = this.webrtc.screenStream(); const localStream = this.screenShare.screenStream();
const localPeerKey = this.getUserPeerKey(me); const localPeerKey = this.getUserPeerKey(me);
if (localStream && localPeerKey) { if (localStream && localPeerKey) {
@@ -306,18 +315,18 @@ export class ScreenShareWorkspaceComponent {
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
this.clearHeaderHideTimeout(); this.clearHeaderHideTimeout();
this.cleanupObservedRemoteStreams(); this.cleanupObservedRemoteStreams();
this.webrtc.syncRemoteScreenShareRequests([], false); this.screenShare.syncRemoteScreenShareRequests([], false);
this.screenSharePlayback.teardownAll(); this.screenSharePlayback.teardownAll();
}); });
this.webrtc.onRemoteStream this.screenShare.onRemoteStream
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ peerId }) => { .subscribe(({ peerId }) => {
this.observeRemoteStream(peerId); this.observeRemoteStream(peerId);
this.bumpRemoteStreamRevision(); this.bumpRemoteStreamRevision();
}); });
this.webrtc.onPeerDisconnected this.screenShare.onPeerDisconnected
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision()); .subscribe(() => this.bumpRemoteStreamRevision());
@@ -363,7 +372,7 @@ export class ScreenShareWorkspaceComponent {
.filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey) .filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey)
)); ));
this.webrtc.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares); this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
if (!shouldConnectRemoteShares) { if (!shouldConnectRemoteShares) {
this.screenSharePlayback.teardownAll(); this.screenSharePlayback.teardownAll();
@@ -614,7 +623,7 @@ export class ScreenShareWorkspaceComponent {
async toggleScreenShare(): Promise<void> { async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
this.webrtc.stopScreenShare(); this.screenShare.stopScreenShare();
return; return;
} }
@@ -656,7 +665,7 @@ export class ScreenShareWorkspaceComponent {
}); });
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
this.webrtc.stopScreenShare(); this.screenShare.stopScreenShare();
} }
this.webrtc.disableVoice(); this.webrtc.disableVoice();
@@ -773,7 +782,7 @@ export class ScreenShareWorkspaceComponent {
}; };
try { try {
await this.webrtc.startScreenShare(options); await this.screenShare.startScreenShare(options);
this.voiceWorkspace.open(null); this.voiceWorkspace.open(null);
} catch { } catch {
@@ -791,7 +800,7 @@ export class ScreenShareWorkspaceComponent {
); );
for (const peerKey of peerKeys) { for (const peerKey of peerKeys) {
const stream = this.webrtc.getRemoteScreenShareStream(peerKey); const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
if (stream && this.hasActiveVideo(stream)) { if (stream && this.hasActiveVideo(stream)) {
return { peerKey, stream }; return { peerKey, stream };
@@ -848,7 +857,7 @@ export class ScreenShareWorkspaceComponent {
} }
private observeRemoteStream(peerKey: string): void { private observeRemoteStream(peerKey: string): void {
const stream = this.webrtc.getRemoteScreenShareStream(peerKey); const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
const existing = this.observedRemoteStreams.get(peerKey); const existing = this.observedRemoteStreams.get(peerKey);
if (!stream) { if (!stream) {

View File

@@ -3,8 +3,9 @@ import {
effect, effect,
inject inject
} from '@angular/core'; } from '@angular/core';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants'; import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
import { ScreenShareFacade } from '../../../../domains/screen-share';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
export interface PlaybackOptions { export interface PlaybackOptions {
isConnected: boolean; isConnected: boolean;
@@ -33,7 +34,8 @@ interface PeerAudioPipeline {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoicePlaybackService { export class VoicePlaybackService {
private webrtc = inject(WebRTCService); private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private peerPipelines = new Map<string, PeerAudioPipeline>(); private peerPipelines = new Map<string, PeerAudioPipeline>();
private pendingRemoteStreams = new Map<string, MediaStream>(); private pendingRemoteStreams = new Map<string, MediaStream>();
@@ -50,20 +52,20 @@ export class VoicePlaybackService {
this.loadPersistedVolumes(); this.loadPersistedVolumes();
effect(() => { effect(() => {
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed(); this.captureEchoSuppressed = this.screenShare.isScreenShareRemotePlaybackSuppressed();
this.recalcAllGains(); this.recalcAllGains();
}); });
effect(() => { effect(() => {
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput() this.temporaryOutputDeviceId = this.screenShare.forceDefaultRemotePlaybackOutput()
? 'default' ? 'default'
: null; : null;
void this.applyEffectiveOutputDeviceToAllPipelines(); void this.applyEffectiveOutputDeviceToAllPipelines();
}); });
this.webrtc.onRemoteStream.subscribe(({ peerId }) => { this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
const voiceStream = this.webrtc.getRemoteVoiceStream(peerId); const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
if (!voiceStream) { if (!voiceStream) {
this.removeRemoteAudio(peerId); this.removeRemoteAudio(peerId);
@@ -73,14 +75,14 @@ export class VoicePlaybackService {
this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions()); this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions());
}); });
this.webrtc.onVoiceConnected.subscribe(() => { this.voiceConnection.onVoiceConnected.subscribe(() => {
const options = this.buildPlaybackOptions(true); const options = this.buildPlaybackOptions(true);
this.playPendingStreams(options); this.playPendingStreams(options);
this.ensureAllRemoteStreamsPlaying(options); this.ensureAllRemoteStreamsPlaying(options);
}); });
this.webrtc.onPeerDisconnected.subscribe((peerId) => { this.voiceConnection.onPeerDisconnected.subscribe((peerId) => {
this.removeRemoteAudio(peerId); this.removeRemoteAudio(peerId);
}); });
} }
@@ -122,10 +124,10 @@ export class VoicePlaybackService {
if (!options.isConnected) if (!options.isConnected)
return; return;
const peers = this.webrtc.getConnectedPeers(); const peers = this.voiceConnection.getConnectedPeers();
for (const peerId of peers) { for (const peerId of peers) {
const stream = this.webrtc.getRemoteVoiceStream(peerId); const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
if (stream && this.hasAudio(stream)) { if (stream && this.hasAudio(stream)) {
const trackedRaw = this.rawRemoteStreams.get(peerId); const trackedRaw = this.rawRemoteStreams.get(peerId);
@@ -181,7 +183,7 @@ export class VoicePlaybackService {
this.pendingRemoteStreams.clear(); this.pendingRemoteStreams.clear();
} }
private buildPlaybackOptions(forceConnected = this.webrtc.isVoiceConnected()): PlaybackOptions { private buildPlaybackOptions(forceConnected = this.voiceConnection.isVoiceConnected()): PlaybackOptions {
return { return {
isConnected: forceConnected, isConnected: forceConnected,
outputVolume: this.masterVolume, outputVolume: this.masterVolume,

View File

@@ -22,15 +22,14 @@ import {
lucideHeadphones lucideHeadphones
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { VoiceSessionFacade } from '../../../domains/voice-session';
import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
import { VoiceActivityService } from '../../../core/services/voice-activity.service'; import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session';
import { ScreenShareQuality } from '../../../core/services/webrtc';
import { import {
DebugConsoleComponent, DebugConsoleComponent,
ScreenShareQualityDialogComponent, ScreenShareQualityDialogComponent,
@@ -69,12 +68,13 @@ interface AudioDevice {
templateUrl: './voice-controls.component.html' templateUrl: './voice-controls.component.html'
}) })
export class VoiceControlsComponent implements OnInit, OnDestroy { export class VoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService); private readonly webrtcService = inject(VoiceConnectionFacade);
private voiceSessionService = inject(VoiceSessionService); private readonly screenShareService = inject(ScreenShareFacade);
private voiceActivity = inject(VoiceActivityService); private readonly voiceSessionService = inject(VoiceSessionFacade);
private voicePlayback = inject(VoicePlaybackService); private readonly voiceActivity = inject(VoiceActivityService);
private store = inject(Store); private readonly voicePlayback = inject(VoicePlaybackService);
private settingsModal = inject(SettingsModalService); private readonly store = inject(Store);
private readonly settingsModal = inject(SettingsModalService);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -84,7 +84,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage()); connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
isMuted = signal(false); isMuted = signal(false);
isDeafened = signal(false); isDeafened = signal(false);
isScreenSharing = this.webrtcService.isScreenSharing; isScreenSharing = this.screenShareService.isScreenSharing;
showSettings = signal(false); showSettings = signal(false);
inputDevices = signal<AudioDevice[]>([]); inputDevices = signal<AudioDevice[]>([]);
@@ -251,7 +251,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Stop screen sharing if active // Stop screen sharing if active
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare(); this.screenShareService.stopScreenShare();
} }
// Untrack local mic from voice-activity visualisation // Untrack local mic from voice-activity visualisation
@@ -366,7 +366,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async toggleScreenShare(): Promise<void> { async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare(); this.screenShareService.stopScreenShare();
} else { } else {
this.syncScreenShareSettings(); this.syncScreenShareSettings();
@@ -533,7 +533,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> { private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
try { try {
await this.webrtcService.startScreenShare({ await this.screenShareService.startScreenShare({
includeSystemAudio: this.includeSystemAudio(), includeSystemAudio: this.includeSystemAudio(),
quality quality
}); });

View File

@@ -8,7 +8,7 @@ import {
Room, Room,
Reaction, Reaction,
BanEntry BanEntry
} from '../models/index'; } from '../../core/models/index';
/** IndexedDB database name for the MetoYou application. */ /** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou'; const DATABASE_NAME = 'metoyou';
@@ -259,15 +259,15 @@ export class BrowserDatabaseService {
async isUserBanned(userId: string, roomId: string): Promise<boolean> { async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const activeBans = await this.getBansForRoom(roomId); const activeBans = await this.getBansForRoom(roomId);
return activeBans.some((ban) => ban.userId === userId || (!ban.userId && ban.oderId === userId)); return activeBans.some((ban) => ban.oderId === userId);
} }
/** Persist an attachment metadata record. */ /** Persist attachment metadata associated with a chat message. */
async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> { async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
await this.put(STORE_ATTACHMENTS, attachment); await this.put(STORE_ATTACHMENTS, attachment);
} }
/** Return all attachment records for a message. */ /** Return all attachment records associated with a message. */
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> { async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId); return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
} }
@@ -277,15 +277,11 @@ export class BrowserDatabaseService {
return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS); return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
} }
/** Delete all attachment records for a message. */ /** Delete every attachment record for a specific message. */
async deleteAttachmentsForMessage(messageId: string): Promise<void> { async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>( const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
STORE_ATTACHMENTS, 'messageId', messageId STORE_ATTACHMENTS, 'messageId', messageId
); );
if (attachments.length === 0)
return;
const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite'); const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite');
for (const attachment of attachments) { for (const attachment of attachments) {
@@ -295,7 +291,7 @@ export class BrowserDatabaseService {
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
/** Wipe every object store, removing all persisted data. */ /** Wipe all persisted data in every object store. */
async clearAllData(): Promise<void> { async clearAllData(): Promise<void> {
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite'); const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
@@ -306,153 +302,140 @@ export class BrowserDatabaseService {
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
// Private helpers - thin wrappers around IndexedDB
/**
* Open (or upgrade) the IndexedDB database and create any missing
* object stores.
*/
private openDatabase(): Promise<IDBDatabase> { private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_MESSAGES)) {
const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' });
messagesStore.createIndex('roomId', 'roomId', { unique: false });
}
if (!database.objectStoreNames.contains(STORE_USERS)) {
database.createObjectStore(STORE_USERS, { keyPath: 'id' });
}
if (!database.objectStoreNames.contains(STORE_ROOMS)) {
database.createObjectStore(STORE_ROOMS, { keyPath: 'id' });
}
if (!database.objectStoreNames.contains(STORE_REACTIONS)) {
const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' });
reactionsStore.createIndex('messageId', 'messageId', { unique: false });
}
if (!database.objectStoreNames.contains(STORE_BANS)) {
const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' });
bansStore.createIndex('roomId', 'roomId', { unique: false });
}
if (!database.objectStoreNames.contains(STORE_META)) {
database.createObjectStore(STORE_META, { keyPath: 'id' });
}
if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) {
const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
attachmentsStore.createIndex('messageId', 'messageId', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
request.onupgradeneeded = () => this.setupSchema(request.result);
request.onsuccess = () => resolve(request.result);
}); });
} }
/** Create an IndexedDB transaction on one or more stores. */ private setupSchema(database: IDBDatabase): void {
private createTransaction( const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });
stores: string | string[],
mode: IDBTransactionMode = 'readonly' this.ensureIndex(messagesStore, 'roomId', 'roomId');
): IDBTransaction { this.ensureIndex(messagesStore, 'timestamp', 'timestamp');
return this.getDatabase().transaction(stores, mode);
this.ensureStore(database, STORE_USERS, { keyPath: 'id' });
const roomsStore = this.ensureStore(database, STORE_ROOMS, { keyPath: 'id' });
this.ensureIndex(roomsStore, 'timestamp', 'timestamp');
const reactionsStore = this.ensureStore(database, STORE_REACTIONS, { keyPath: 'id' });
this.ensureIndex(reactionsStore, 'messageId', 'messageId');
this.ensureIndex(reactionsStore, 'userId', 'userId');
const bansStore = this.ensureStore(database, STORE_BANS, { keyPath: 'oderId' });
this.ensureIndex(bansStore, 'roomId', 'roomId');
this.ensureIndex(bansStore, 'expiresAt', 'expiresAt');
this.ensureStore(database, STORE_META, { keyPath: 'id' });
const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' });
this.ensureIndex(attachmentsStore, 'messageId', 'messageId');
} }
private getDatabase(): IDBDatabase { private ensureStore(
if (!this.database) { database: IDBDatabase,
throw new Error('Browser database is not initialized'); name: string,
options?: IDBObjectStoreParameters
): IDBObjectStore {
if (database.objectStoreNames.contains(name)) {
return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name);
} }
return this.database; return database.createObjectStore(name, options);
}
private ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, { unique: false });
}
}
private createTransaction(
storeNames: string | string[],
mode: IDBTransactionMode
): IDBTransaction {
if (!this.database) {
throw new Error('Database has not been initialized');
}
return this.database.transaction(storeNames, mode);
} }
/** Wrap a transaction's completion event as a Promise. */
private awaitTransaction(transaction: IDBTransaction): Promise<void> { private awaitTransaction(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
}); });
} }
/** Retrieve a single record by primary key. */ private async put(storeName: string, value: unknown): Promise<void> {
private get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> { const transaction = this.createTransaction(storeName, 'readwrite');
return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName);
const request = transaction.objectStore(storeName).get(key);
transaction.objectStore(storeName).put(value);
await this.awaitTransaction(transaction);
}
private async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
const transaction = this.createTransaction(storeName, 'readonly');
const request = transaction.objectStore(storeName).get(key);
return new Promise<T | undefined>((resolve, reject) => {
request.onsuccess = () => resolve(request.result as T | undefined); request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} }
/** Retrieve every record from an object store. */ private async getAll<T>(storeName: string): Promise<T[]> {
private getAll<T>(storeName: string): Promise<T[]> { const transaction = this.createTransaction(storeName, 'readonly');
return new Promise((resolve, reject) => { const request = transaction.objectStore(storeName).getAll();
const transaction = this.createTransaction(storeName);
const request = transaction.objectStore(storeName).getAll();
request.onsuccess = () => resolve(request.result as T[]); return new Promise<T[]>((resolve, reject) => {
request.onsuccess = () => resolve((request.result as T[]) ?? []);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} }
/** Retrieve all records from an index that match a key. */ private async getAllFromIndex<T>(
private getAllFromIndex<T>(
storeName: string, storeName: string,
indexName: string, indexName: string,
key: IDBValidKey query: IDBValidKey | IDBKeyRange
): Promise<T[]> { ): Promise<T[]> {
return new Promise((resolve, reject) => { const transaction = this.createTransaction(storeName, 'readonly');
const transaction = this.createTransaction(storeName); const request = transaction.objectStore(storeName)
const index = transaction.objectStore(storeName).index(indexName); .index(indexName)
const request = index.getAll(key); .getAll(query);
request.onsuccess = () => resolve(request.result as T[]); return new Promise<T[]>((resolve, reject) => {
request.onsuccess = () => resolve((request.result as T[]) ?? []);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} }
/** Insert or update a record in the given object store. */ private async deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
private put<T>(storeName: string, value: T): Promise<void> { const transaction = this.createTransaction(storeName, 'readwrite');
return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(value); transaction.objectStore(storeName).delete(key);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
/** Delete a record by primary key. */ await this.awaitTransaction(transaction);
private deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).delete(key);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
} }
private normaliseMessage(message: Message): Message { private normaliseMessage(message: Message): Message {
if (!message.isDeleted) if (message.content === DELETED_MESSAGE_CONTENT) {
return message; return { ...message,
reactions: [] };
}
return { return message;
...message,
content: DELETED_MESSAGE_CONTENT,
reactions: []
};
} }
} }

View File

@@ -11,8 +11,8 @@ import {
Reaction, Reaction,
BanEntry, BanEntry,
ChatAttachmentMeta ChatAttachmentMeta
} from '../models/index'; } from '../../core/models/index';
import { PlatformService } from './platform.service'; import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service'; import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-database.service'; import { ElectronDatabaseService } from './electron-database.service';
@@ -20,10 +20,10 @@ import { ElectronDatabaseService } from './electron-database.service';
* Facade database service that transparently delegates to the correct * Facade database service that transparently delegates to the correct
* storage backend based on the runtime platform. * storage backend based on the runtime platform.
* *
* - **Electron** SQLite via {@link ElectronDatabaseService} (IPC to main process). * - Electron -> SQLite via {@link ElectronDatabaseService} (IPC to main process).
* - **Browser** IndexedDB via {@link BrowserDatabaseService}. * - Browser -> IndexedDB via {@link BrowserDatabaseService}.
* *
* All consumers inject `DatabaseService` - the underlying storage engine * All consumers inject `DatabaseService`; the underlying storage engine
* is selected automatically. * is selected automatically.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -1,35 +1,28 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import {
Message, Message,
User, User,
Room, Room,
Reaction, Reaction,
BanEntry BanEntry
} from '../models/index'; } from '../../core/models/index';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
/** CQRS API exposed by the Electron preload script via `contextBridge`. */ import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
interface ElectronAPI {
command<T = unknown>(command: { type: string; payload: unknown }): Promise<T>;
query<T = unknown>(query: { type: string; payload: unknown }): Promise<T>;
}
/** /**
* Database service for the Electron (desktop) runtime. * Database service for the Electron (desktop) runtime.
* *
* The SQLite database is managed by TypeORM in the Electron **main process** * The SQLite database is managed by TypeORM in the Electron main process.
* (`electron/main.ts`). This service is a thin CQRS IPC client that dispatches * This service is a thin CQRS IPC client that dispatches structured
* structured command/query objects through the unified `cqrs:command` and * command/query objects through the unified preload channels.
* `cqrs:query` channels exposed by the preload script.
*
* No initialisation IPC call is needed - the database is initialised and
* migrations are run in main.ts before the renderer window is created.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ElectronDatabaseService { export class ElectronDatabaseService {
private readonly electronBridge = inject(ElectronBridgeService);
/** Shorthand accessor for the preload-exposed CQRS API. */ /** Shorthand accessor for the preload-exposed CQRS API. */
private get api(): ElectronAPI { private get api(): ElectronApi {
// eslint-disable-next-line return this.electronBridge.requireApi();
return (window as any).electronAPI as ElectronAPI;
} }
/** /**

View File

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

View File

@@ -0,0 +1,18 @@
export { WebRTCService } from './realtime-session.service';
export * from './realtime.constants';
export * from './realtime.types';
export * from './screen-share.config';
export * from './logging/webrtc-logger';
export * from './media/media.manager';
export * from './media/noise-reduction.manager';
export * from './media/screen-share.manager';
export * from './media/voice-session-controller';
export * from './signaling/server-signaling-coordinator';
export * from './signaling/signaling-message-handler';
export * from './signaling/server-membership-signaling-handler';
export * from './signaling/signaling.manager';
export * from './signaling/signaling-transport-handler';
export * from './streams/peer-media-facade';
export * from './streams/remote-screen-share-request-controller';
export * from './state/webrtc-state-controller';
export * from './peer-connection.manager';

View File

@@ -5,9 +5,10 @@
* and optional RNNoise-based noise reduction. * and optional RNNoise-based noise reduction.
*/ */
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ChatEvent } from '../../models'; import { ChatEvent } from '../../../core/models';
import { WebRTCLogger } from './webrtc-logger'; import { LatencyProfile } from '../realtime.constants';
import { PeerData } from './webrtc.types'; import { PeerData } from '../realtime.types';
import { WebRTCLogger } from '../logging/webrtc-logger';
import { NoiseReductionManager } from './noise-reduction.manager'; import { NoiseReductionManager } from './noise-reduction.manager';
import { import {
TRACK_KIND_AUDIO, TRACK_KIND_AUDIO,
@@ -23,9 +24,8 @@ import {
VOLUME_MAX, VOLUME_MAX,
VOICE_HEARTBEAT_INTERVAL_MS, VOICE_HEARTBEAT_INTERVAL_MS,
DEFAULT_DISPLAY_NAME, DEFAULT_DISPLAY_NAME,
P2P_TYPE_VOICE_STATE, P2P_TYPE_VOICE_STATE
LatencyProfile } from '../realtime.constants';
} from './webrtc.constants';
/** /**
* Callbacks the MediaManager needs from the owning service / peer manager. * Callbacks the MediaManager needs from the owning service / peer manager.

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