ddd test
This commit is contained in:
@@ -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
139
docs/architecture.md
Normal 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.
@@ -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' ||
|
||||||
|
|||||||
150
src/app/core/platform/electron/electron-api.models.ts
Normal file
150
src/app/core/platform/electron/electron-api.models.ts
Normal 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;
|
||||||
|
};
|
||||||
24
src/app/core/platform/electron/electron-bridge.service.ts
Normal file
24
src/app/core/platform/electron/electron-bridge.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app/core/platform/electron/get-electron-api.ts
Normal file
7
src/app/core/platform/electron/get-electron-api.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
2
src/app/core/platform/index.ts
Normal file
2
src/app/core/platform/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './platform.service';
|
||||||
|
export * from './external-link.service';
|
||||||
15
src/app/core/platform/platform.service.ts
Normal file
15
src/app/core/platform/platform.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/app/core/realtime/index.ts
Normal file
3
src/app/core/realtime/index.ts
Normal 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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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';
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/app/domains/attachment/application/attachment.facade.ts
Normal file
119
src/app/domains/attachment/application/attachment.facade.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.';
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
19
src/app/domains/attachment/domain/attachment.logic.ts
Normal file
19
src/app/domains/attachment/domain/attachment.logic.ts
Normal 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/');
|
||||||
|
}
|
||||||
13
src/app/domains/attachment/domain/attachment.models.ts
Normal file
13
src/app/domains/attachment/domain/attachment.models.ts
Normal 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;
|
||||||
|
}
|
||||||
3
src/app/domains/attachment/index.ts
Normal file
3
src/app/domains/attachment/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './application/attachment.facade';
|
||||||
|
export * from './domain/attachment.constants';
|
||||||
|
export * from './domain/attachment.models';
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
1
src/app/domains/auth/index.ts
Normal file
1
src/app/domains/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './application/auth.service';
|
||||||
@@ -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
|
||||||
1
src/app/domains/chat/index.ts
Normal file
1
src/app/domains/chat/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './application/klipy.service';
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
3
src/app/domains/screen-share/index.ts
Normal file
3
src/app/domains/screen-share/index.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
3
src/app/domains/server-directory/index.ts
Normal file
3
src/app/domains/server-directory/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './application/server-directory.facade';
|
||||||
|
export * from './domain/server-directory.constants';
|
||||||
|
export * from './domain/server-directory.models';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
LATENCY_PROFILE_BITRATES,
|
||||||
|
type LatencyProfile
|
||||||
|
} from '../../../infrastructure/realtime/realtime.constants';
|
||||||
|
export type { VoiceStateSnapshot } from '../../../infrastructure/realtime/realtime.types';
|
||||||
3
src/app/domains/voice-connection/index.ts
Normal file
3
src/app/domains/voice-connection/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './application/voice-connection.facade';
|
||||||
|
export * from './application/voice-activity.service';
|
||||||
|
export * from './domain/voice-connection.models';
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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);
|
||||||
23
src/app/domains/voice-session/domain/voice-session.logic.ts
Normal file
23
src/app/domains/voice-session/domain/voice-session.logic.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/app/domains/voice-session/domain/voice-session.models.ts
Normal file
21
src/app/domains/voice-session/domain/voice-session.models.ts
Normal 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;
|
||||||
|
}
|
||||||
4
src/app/domains/voice-session/index.ts
Normal file
4
src/app/domains/voice-session/index.ts
Normal 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';
|
||||||
@@ -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',
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: []
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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' })
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
1
src/app/infrastructure/persistence/index.ts
Normal file
1
src/app/infrastructure/persistence/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './database.service';
|
||||||
18
src/app/infrastructure/realtime/index.ts
Normal file
18
src/app/infrastructure/realtime/index.ts
Normal 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';
|
||||||
@@ -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
Reference in New Issue
Block a user