ddd test
This commit is contained in:
@@ -6,6 +6,12 @@ Desktop chat app with three parts:
|
||||
- `electron/` desktop shell, IPC, and local database
|
||||
- `server/` directory server, join request API, and websocket events
|
||||
|
||||
## Architecture
|
||||
|
||||
- Renderer architecture and refactor conventions live in `docs/architecture.md`
|
||||
- Electron renderer integrations should go through `src/app/core/platform/electron/`
|
||||
- Pure shared logic belongs in `src/app/core/helpers/` and reusable contracts belong in `src/app/core/models/`
|
||||
|
||||
## Install
|
||||
|
||||
1. Run `npm install`
|
||||
|
||||
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 { 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 { ServerDirectoryService } from './core/services/server-directory.service';
|
||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
||||
import { ExternalLinkService } from './core/services/external-link.service';
|
||||
import { VoiceSessionFacade } from './domains/voice-session';
|
||||
import { ExternalLinkService } from './core/platform';
|
||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
@@ -36,15 +37,6 @@ import {
|
||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||
} from './core/constants';
|
||||
|
||||
interface DeepLinkElectronApi {
|
||||
consumePendingDeepLink?: () => Promise<string | null>;
|
||||
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
|
||||
}
|
||||
|
||||
type DeepLinkWindow = Window & {
|
||||
electronAPI?: DeepLinkElectronApi;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
@@ -68,11 +60,12 @@ export class App implements OnInit, OnDestroy {
|
||||
|
||||
private databaseService = inject(DatabaseService);
|
||||
private router = inject(Router);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private servers = inject(ServerDirectoryFacade);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private deepLinkCleanup: (() => void) | null = null;
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
@@ -155,7 +148,7 @@ export class App implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async setupDesktopDeepLinks(): Promise<void> {
|
||||
const electronApi = this.getDeepLinkElectronApi();
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
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 {
|
||||
return url === '/login' ||
|
||||
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 { PlatformService } from './platform.service';
|
||||
|
||||
interface ExternalLinkElectronApi {
|
||||
openExternal?: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
type ExternalLinkWindow = Window & {
|
||||
electronAPI?: ExternalLinkElectronApi;
|
||||
};
|
||||
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||
|
||||
/**
|
||||
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
||||
@@ -17,18 +9,21 @@ type ExternalLinkWindow = Window & {
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExternalLinkService {
|
||||
private platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
/** Open a URL externally. Only http/https URLs are allowed. */
|
||||
open(url: string): void {
|
||||
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
|
||||
return;
|
||||
|
||||
if (this.platform.isElectron) {
|
||||
(window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
void electronApi.openExternal(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,22 +36,19 @@ export class ExternalLinkService {
|
||||
if (!target)
|
||||
return false;
|
||||
|
||||
const href = target.href; // resolved full URL
|
||||
const href = target.href;
|
||||
|
||||
if (!href)
|
||||
return false;
|
||||
|
||||
// Skip non-navigable URLs
|
||||
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
|
||||
return false;
|
||||
|
||||
// Skip same-page anchors
|
||||
const rawAttr = target.getAttribute('href');
|
||||
|
||||
if (rawAttr?.startsWith('#'))
|
||||
return false;
|
||||
|
||||
// Skip Angular router links
|
||||
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
|
||||
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 */
|
||||
import { getDebugNetworkMetricSnapshot } from '../debug-network-metrics.service';
|
||||
import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||
import type { Room, User } from '../../models/index';
|
||||
import {
|
||||
LOCAL_NETWORK_NODE_ID,
|
||||
|
||||
@@ -5,65 +5,16 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service';
|
||||
|
||||
type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
type DesktopUpdateStatus =
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| '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>;
|
||||
}
|
||||
import { PlatformService } from '../platform';
|
||||
import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import {
|
||||
type AutoUpdateMode,
|
||||
type DesktopUpdateServerContext,
|
||||
type DesktopUpdateServerVersionStatus,
|
||||
type DesktopUpdateState,
|
||||
type ElectronApi
|
||||
} from '../platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
|
||||
interface ServerHealthResponse {
|
||||
releaseManifestUrl?: string;
|
||||
@@ -77,10 +28,6 @@ interface ServerHealthSnapshot {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
type DesktopUpdateWindow = Window & {
|
||||
electronAPI?: DesktopUpdateElectronApi;
|
||||
};
|
||||
|
||||
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -153,7 +100,8 @@ export class DesktopAppUpdateService {
|
||||
readonly state = signal<DesktopUpdateState>(createInitialState());
|
||||
|
||||
private injector = inject(Injector);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private servers = inject(ServerDirectoryFacade);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private initialized = false;
|
||||
private refreshTimerId: number | null = null;
|
||||
private removeStateListener: (() => void) | null = null;
|
||||
@@ -393,9 +341,7 @@ export class DesktopAppUpdateService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getElectronApi(): DesktopUpdateElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as DesktopUpdateWindow).electronAPI ?? null
|
||||
: null;
|
||||
private getElectronApi(): ElectronApi | null {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
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 './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';
|
||||
|
||||
@@ -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 { HttpClient } from '@angular/common/http';
|
||||
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).
|
||||
@@ -20,14 +20,14 @@ export interface LoginResponse {
|
||||
* Handles user authentication (login and registration) against a
|
||||
* 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
|
||||
* server endpoint is used.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
/**
|
||||
* 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
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerDirectoryService } from './server-directory.service';
|
||||
import { ServerDirectoryFacade } from '../../server-directory';
|
||||
|
||||
export interface KlipyGif {
|
||||
id: string;
|
||||
@@ -41,7 +41,7 @@ const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KlipyService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly availabilityState = signal({
|
||||
enabled: false,
|
||||
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,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from './voice-settings.storage';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc/webrtc.constants';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen';
|
||||
|
||||
export const DEFAULT_SCREEN_SHARE_QUALITY: ScreenShareQuality = 'balanced';
|
||||
|
||||
export const DEFAULT_SCREEN_SHARE_START_OPTIONS: ScreenShareStartOptions = {
|
||||
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
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { DebuggingService } from './debugging.service';
|
||||
import { WebRTCService } from './webrtc.service';
|
||||
import { VoiceConnectionFacade } from './voice-connection.facade';
|
||||
import { DebuggingService } from '../../../core/services/debugging.service';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||
|
||||
const SPEAKING_THRESHOLD = 0.015;
|
||||
@@ -46,7 +46,7 @@ interface TrackedStream {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceActivityService implements OnDestroy {
|
||||
private readonly webrtc = inject(WebRTCService);
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly tracked = new Map<string, TrackedStream>();
|
||||
@@ -58,8 +58,8 @@ export class VoiceActivityService implements OnDestroy {
|
||||
|
||||
constructor() {
|
||||
this.subs.push(
|
||||
this.webrtc.onRemoteStream.subscribe(({ peerId }) => {
|
||||
const voiceStream = this.webrtc.getRemoteVoiceStream(peerId);
|
||||
this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
|
||||
const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (!voiceStream) {
|
||||
this.untrackStream(peerId);
|
||||
@@ -71,7 +71,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
this.webrtc.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.voiceConnection.onPeerDisconnected.subscribe((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,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Room } from '../models';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { buildVoiceSessionRoom, isViewingVoiceSessionServer } from '../domain/voice-session.logic';
|
||||
import type { VoiceSessionInfo } from '../domain/voice-session.models';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceSessionService {
|
||||
private readonly router = inject(Router);
|
||||
export class VoiceSessionFacade {
|
||||
private readonly store = inject(Store);
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
checkCurrentRoute(currentServerId: string | null): void {
|
||||
const session = this._voiceSession();
|
||||
|
||||
if (!session) {
|
||||
this._isViewingVoiceServer.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this._isViewingVoiceServer.set(currentServerId === session.serverId);
|
||||
this._isViewingVoiceServer.set(
|
||||
isViewingVoiceSessionServer(this._voiceSession(), currentServerId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,21 +95,9 @@ export class VoiceSessionService {
|
||||
if (!session)
|
||||
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(
|
||||
RoomsActions.viewServer({
|
||||
room
|
||||
room: buildVoiceSessionRoom(session)
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { VoiceSessionService } from './voice-session.service';
|
||||
import { VoiceSessionFacade } from './voice-session.facade';
|
||||
|
||||
export type VoiceWorkspaceMode = 'hidden' | 'expanded' | 'minimized';
|
||||
|
||||
@@ -23,7 +23,7 @@ const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceWorkspaceService {
|
||||
private readonly voiceSession = inject(VoiceSessionService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
|
||||
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 { LatencyProfile } from './webrtc/webrtc.constants';
|
||||
import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from './webrtc/screen-share.config';
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||
import { LatencyProfile } from '../../voice-connection/domain/voice-connection.models';
|
||||
import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from '../../screen-share/domain/screen-share.config';
|
||||
|
||||
const LATENCY_PROFILES: LatencyProfile[] = [
|
||||
'low',
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
selectOnlineUsers
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, User } from '../../../core/models/index';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
@@ -93,7 +93,7 @@ export class AdminPanelComponent {
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
private webrtc = inject(WebRTCService);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { AuthService } from '../../../domains/auth';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
@@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
*/
|
||||
export class LoginComponent {
|
||||
serversSvc = inject(ServerDirectoryService);
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { AuthService } from '../../../domains/auth';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
@@ -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.
|
||||
*/
|
||||
export class RegisterComponent {
|
||||
serversSvc = inject(ServerDirectoryService);
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Attachment, AttachmentService } from '../../../core/services/attachment.service';
|
||||
import { KlipyGif } from '../../../core/services/klipy.service';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { Attachment, AttachmentFacade } from '../../../domains/attachment';
|
||||
import { KlipyGif } from '../../../domains/chat';
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import {
|
||||
selectAllMessages,
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||
@@ -48,9 +49,10 @@ import {
|
||||
export class ChatMessagesComponent {
|
||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(WebRTCService);
|
||||
private readonly attachmentsSvc = inject(AttachmentService);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
|
||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
@@ -252,17 +254,9 @@ export class ChatMessagesComponent {
|
||||
if (!attachment.available || !attachment.objectUrl)
|
||||
return;
|
||||
|
||||
const electronWindow = window as Window & {
|
||||
electronAPI?: {
|
||||
saveFileAs?: (
|
||||
defaultFileName: string,
|
||||
data: string
|
||||
) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
};
|
||||
};
|
||||
const electronApi = electronWindow.electronAPI;
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi?.saveFileAs) {
|
||||
if (electronApi) {
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
|
||||
@@ -19,28 +19,14 @@ import {
|
||||
lucideSend,
|
||||
lucideX
|
||||
} 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 { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||
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 & {
|
||||
path?: string;
|
||||
};
|
||||
@@ -87,6 +73,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
readonly klipy = inject(KlipyService);
|
||||
private readonly markdown = inject(ChatMarkdownService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
readonly toolbarVisible = signal(false);
|
||||
@@ -558,9 +545,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async readFilesFromElectronClipboard(): Promise<File[]> {
|
||||
const electronApi = (window as ClipboardWindow).electronAPI;
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.readClipboardFiles)
|
||||
if (!electronApi)
|
||||
return [];
|
||||
|
||||
try {
|
||||
|
||||
@@ -31,10 +31,10 @@ import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentService,
|
||||
AttachmentFacade,
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../core/services/attachment.service';
|
||||
import { KlipyService } from '../../../../../core/services/klipy.service';
|
||||
} from '../../../../../domains/attachment';
|
||||
import { KlipyService } from '../../../../../domains/chat';
|
||||
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
@@ -126,7 +126,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
export class ChatMessageItemComponent {
|
||||
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentService);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Attachment } from '../../../../../core/services/attachment.service';
|
||||
import { Attachment } from '../../../../../domains/attachment';
|
||||
import { Message } from '../../../../../core/models';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
lucideDownload,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { Attachment } from '../../../../../core/services/attachment.service';
|
||||
import { Attachment } from '../../../../../domains/attachment';
|
||||
import { ContextMenuComponent } from '../../../../../shared';
|
||||
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';
|
||||
|
||||
export interface ChatMessageComposerSubmitEvent {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
lucideSearch,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
|
||||
import { KlipyGif, KlipyService } from '../../../domains/chat';
|
||||
|
||||
@Component({
|
||||
selector: 'app-klipy-gif-picker',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
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 {
|
||||
merge,
|
||||
@@ -48,7 +48,7 @@ export class TypingIndicatorComponent {
|
||||
typingOthersCount = signal<number>(0);
|
||||
|
||||
constructor() {
|
||||
const webrtc = inject(WebRTCService);
|
||||
const webrtc = inject(RealtimeSessionFacade);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||
filter((msg): msg is TypingSignalingMessage =>
|
||||
|
||||
@@ -11,9 +11,10 @@ import { Store } from '@ngrx/store';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { 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 { DatabaseService } from '../../core/services/database.service';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { User } from '../../core/models/index';
|
||||
|
||||
@Component({
|
||||
@@ -31,7 +32,7 @@ export class InviteComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
|
||||
@@ -34,10 +34,10 @@ import {
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
|
||||
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service';
|
||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||
import {
|
||||
@@ -87,8 +87,10 @@ type TabView = 'channels' | 'users';
|
||||
})
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private voiceSessionService = inject(VoiceSessionService);
|
||||
private realtime = inject(RealtimeSessionFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private screenShare = inject(ScreenShareFacade);
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
voiceActivity = inject(VoiceActivityService);
|
||||
@@ -283,12 +285,12 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
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 };
|
||||
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, inventoryRequest);
|
||||
this.realtime.sendToPeer(pid, inventoryRequest);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -362,7 +364,7 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
this.webrtc.broadcastMessage({
|
||||
this.realtime.broadcastMessage({
|
||||
type: 'role-change',
|
||||
roomId,
|
||||
targetUserId: user.id,
|
||||
@@ -403,7 +405,7 @@ export class RoomsSidePanelComponent {
|
||||
return;
|
||||
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
if (!this.webrtc.isVoiceConnected()) {
|
||||
if (!this.voiceConnection.isVoiceConnected()) {
|
||||
if (current.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
@@ -424,7 +426,7 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
|
||||
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
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
@@ -441,7 +443,7 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
private trackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
const micStream = this.webrtc.getRawMicStream();
|
||||
const micStream = this.voiceConnection.getRawMicStream();
|
||||
|
||||
if (userId && micStream) {
|
||||
this.voiceActivity.trackLocalMic(userId, micStream);
|
||||
@@ -475,11 +477,11 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
|
||||
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 {
|
||||
this.webrtc.broadcastMessage({
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
@@ -514,11 +516,11 @@ export class RoomsSidePanelComponent {
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
||||
return;
|
||||
|
||||
this.webrtc.stopVoiceHeartbeat();
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
|
||||
this.untrackCurrentUserMic();
|
||||
|
||||
this.webrtc.disableVoice();
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
if (current?.id) {
|
||||
this.store.dispatch(
|
||||
@@ -535,7 +537,7 @@ export class RoomsSidePanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
@@ -573,7 +575,7 @@ export class RoomsSidePanelComponent {
|
||||
const me = this.currentUser();
|
||||
|
||||
if (me?.id === userId) {
|
||||
return this.webrtc.isScreenSharing();
|
||||
return this.screenShare.isScreenSharing();
|
||||
}
|
||||
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
|
||||
@@ -590,7 +592,7 @@ export class RoomsSidePanelComponent {
|
||||
(candidate): candidate is string => !!candidate
|
||||
);
|
||||
const stream = peerKeys
|
||||
.map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey))
|
||||
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
|
||||
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
|
||||
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
@@ -636,7 +638,7 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
User
|
||||
} from '../../core/models/index';
|
||||
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent } from '../../shared';
|
||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||
@@ -75,7 +75,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ import { lucidePlus } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
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 { 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 { DatabaseService } from '../../core/services/database.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
@@ -47,10 +47,10 @@ import {
|
||||
export class ServersRailComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private banLookupRequestVersion = 0;
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Store } from '@ngrx/store';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
|
||||
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 { UsersActions } from '../../../../store/users/users.actions';
|
||||
|
||||
|
||||
@@ -8,20 +8,8 @@ import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePower } from '@ng-icons/lucide';
|
||||
|
||||
import { PlatformService } from '../../../../core/services/platform.service';
|
||||
|
||||
interface DesktopSettingsSnapshot {
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
interface GeneralSettingsElectronApi {
|
||||
getDesktopSettings?: () => Promise<DesktopSettingsSnapshot>;
|
||||
setDesktopSettings?: (patch: { autoStart?: boolean }) => Promise<DesktopSettingsSnapshot>;
|
||||
}
|
||||
|
||||
type GeneralSettingsWindow = Window & {
|
||||
electronAPI?: GeneralSettingsElectronApi;
|
||||
};
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
|
||||
@Component({
|
||||
selector: 'app-general-settings',
|
||||
@@ -36,6 +24,7 @@ type GeneralSettingsWindow = Window & {
|
||||
})
|
||||
export class GeneralSettingsComponent {
|
||||
private platform = inject(PlatformService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
autoStart = signal(false);
|
||||
@@ -50,9 +39,9 @@ export class GeneralSettingsComponent {
|
||||
async onAutoStartChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -71,9 +60,9 @@ export class GeneralSettingsComponent {
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.getElectronApi();
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.getDesktopSettings) {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,10 +72,4 @@ export class GeneralSettingsComponent {
|
||||
this.autoStart.set(snapshot.autoStart);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getElectronApi(): GeneralSettingsElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as GeneralSettingsWindow).electronAPI ?? null
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
RoomMember,
|
||||
UserRole
|
||||
} from '../../../../core/models/index';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
@@ -46,7 +46,7 @@ interface ServerMemberView extends RoomMember {
|
||||
})
|
||||
export class MembersSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private webrtcService = inject(RealtimeSessionFacade);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
lucideX
|
||||
} 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';
|
||||
|
||||
@Component({
|
||||
@@ -43,7 +43,7 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
templateUrl: './network-settings.component.html'
|
||||
})
|
||||
export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
|
||||
@@ -25,11 +25,11 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { Room, UserRole } from '../../../core/models/index';
|
||||
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
|
||||
import { GeneralSettingsComponent } from './general-settings/general-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 {
|
||||
readonly modal = inject(SettingsModalService);
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
|
||||
@@ -16,34 +16,20 @@ import {
|
||||
lucideCpu
|
||||
} 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 { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { PlatformService } from '../../../../core/services/platform.service';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../core/services/voice-settings.storage';
|
||||
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../core/services/webrtc';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: 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({
|
||||
selector: 'app-voice-settings',
|
||||
standalone: true,
|
||||
@@ -64,8 +50,9 @@ type DesktopSettingsWindow = Window & {
|
||||
templateUrl: './voice-settings.component.html'
|
||||
})
|
||||
export class VoiceSettingsComponent {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private platform = inject(PlatformService);
|
||||
readonly audioService = inject(NotificationAudioService);
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
@@ -135,13 +122,13 @@ export class VoiceSettingsComponent {
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
|
||||
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) {
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
if (this.noiseReduction() !== this.voiceConnection.isNoiseReductionEnabled()) {
|
||||
this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
|
||||
}
|
||||
|
||||
// Apply persisted volume levels to the live audio pipelines
|
||||
this.webrtcService.setInputVolume(this.inputVolume() / 100);
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voiceConnection.setInputVolume(this.inputVolume() / 100);
|
||||
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
}
|
||||
|
||||
@@ -171,7 +158,7 @@ export class VoiceSettingsComponent {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
@@ -179,7 +166,7 @@ export class VoiceSettingsComponent {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setInputVolume(this.inputVolume() / 100);
|
||||
this.voiceConnection.setInputVolume(this.inputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
@@ -187,7 +174,7 @@ export class VoiceSettingsComponent {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
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.saveVoiceSettings();
|
||||
}
|
||||
@@ -197,7 +184,7 @@ export class VoiceSettingsComponent {
|
||||
const profile = select.value as 'low' | 'balanced' | 'high';
|
||||
|
||||
this.latencyProfile.set(profile);
|
||||
this.webrtcService.setLatencyProfile(profile);
|
||||
this.voiceConnection.setLatencyProfile(profile);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
@@ -205,7 +192,7 @@ export class VoiceSettingsComponent {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.audioBitrate.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setAudioBitrate(this.audioBitrate());
|
||||
this.voiceConnection.setAudioBitrate(this.audioBitrate());
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
@@ -232,7 +219,7 @@ export class VoiceSettingsComponent {
|
||||
|
||||
async onNoiseReductionChange(): Promise<void> {
|
||||
this.noiseReduction.update((currentValue) => !currentValue);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
@@ -249,9 +236,9 @@ export class VoiceSettingsComponent {
|
||||
async onHardwareAccelerationChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const enabled = !!input.checked;
|
||||
const api = this.getElectronApi();
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.setDesktopSettings) {
|
||||
if (!api) {
|
||||
this.hardwareAcceleration.set(enabled);
|
||||
return;
|
||||
}
|
||||
@@ -266,17 +253,17 @@ export class VoiceSettingsComponent {
|
||||
}
|
||||
|
||||
async restartDesktopApp(): Promise<void> {
|
||||
const api = this.getElectronApi();
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (api?.relaunchApp) {
|
||||
if (api) {
|
||||
await api.relaunchApp();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.getElectronApi();
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.getDesktopSettings) {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -291,10 +278,4 @@ export class VoiceSettingsComponent {
|
||||
this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
|
||||
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
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { VoiceConnectionFacade } from '../../domains/voice-connection';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
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.
|
||||
*/
|
||||
export class SettingsComponent implements OnInit {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private router = inject(Router);
|
||||
audioService = inject(NotificationAudioService);
|
||||
|
||||
@@ -184,8 +184,8 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
// Sync the live WebRTC state with the persisted preference
|
||||
if (this.noiseReduction !== this.webrtcService.isNoiseReductionEnabled()) {
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||
if (this.noiseReduction !== this.voiceConnection.isNoiseReductionEnabled()) {
|
||||
this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,6 @@ export class SettingsComponent implements OnInit {
|
||||
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';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { PlatformService } from '../../core/services/platform.service';
|
||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../shared';
|
||||
import { Room } from '../../core/models/index';
|
||||
|
||||
interface WindowControlsAPI {
|
||||
minimizeWindow?: () => void;
|
||||
maximizeWindow?: () => void;
|
||||
closeWindow?: () => void;
|
||||
}
|
||||
|
||||
type ElectronWindow = Window & {
|
||||
electronAPI?: WindowControlsAPI;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
standalone: true,
|
||||
@@ -67,13 +58,14 @@ type ElectronWindow = Window & {
|
||||
*/
|
||||
export class TitleBarComponent {
|
||||
private store = inject(Store);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private router = inject(Router);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private getWindowControlsApi(): WindowControlsAPI | undefined {
|
||||
return (window as ElectronWindow).electronAPI;
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
|
||||
isElectron = computed(() => this.platform.isElectron);
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
lucideArrowLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage';
|
||||
import { ScreenShareQuality } from '../../../core/services/webrtc';
|
||||
import { VoiceSessionFacade } from '../../../domains/voice-session';
|
||||
import { VoiceConnectionFacade } from '../../../domains/voice-connection';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
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.
|
||||
*/
|
||||
export class FloatingVoiceControlsComponent implements OnInit {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private voiceSessionService = inject(VoiceSessionService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private store = inject(Store);
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
@@ -69,7 +70,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = this.webrtcService.isScreenSharing;
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
includeSystemAudio = signal(false);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
@@ -143,7 +144,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
/** Toggle screen sharing on or off. */
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.screenShareService.stopScreenShare();
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
@@ -186,7 +187,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.screenShareService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Disable voice
|
||||
@@ -281,7 +282,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
try {
|
||||
await this.webrtcService.startScreenShare({
|
||||
await this.screenShareService.startScreenShare({
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
lucideMonitor
|
||||
} 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 { User } from '../../../core/models/index';
|
||||
import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||
@@ -46,9 +46,9 @@ import { VoicePlaybackService } from '../voice-controls/services/voice-playback.
|
||||
export class ScreenShareViewerComponent implements OnDestroy {
|
||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private store = inject(Store);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
private remoteStreamSub: Subscription | null = null;
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
@@ -69,7 +69,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
if (!userId)
|
||||
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;
|
||||
|
||||
if (stream && stream.getVideoTracks().length > 0) {
|
||||
@@ -94,7 +94,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
constructor() {
|
||||
// React to screen share stream changes
|
||||
effect(() => {
|
||||
const screenStream = this.webrtcService.screenStream();
|
||||
const screenStream = this.screenShareService.screenStream();
|
||||
|
||||
if (screenStream && this.videoRef) {
|
||||
// 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
|
||||
const stream = this.webrtcService.getRemoteScreenShareStream(watchingId);
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(watchingId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
@@ -141,12 +141,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
// Subscribe to remote streams with video (screen shares)
|
||||
// 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.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId }) => {
|
||||
this.remoteStreamSub = this.screenShareService.onRemoteStream.subscribe(({ peerId }) => {
|
||||
if (peerId !== this.watchingUserId() || this.isLocalShare()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = this.webrtcService.getRemoteScreenShareStream(peerId);
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(peerId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false;
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
@@ -202,7 +202,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
|
||||
/** Stop the local screen share and reset viewer state. */
|
||||
stopSharing(): void {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.screenShareService.stopScreenShare();
|
||||
this.activeScreenSharer.set(null);
|
||||
this.hasStream.set(false);
|
||||
this.isLocalShare.set(false);
|
||||
|
||||
@@ -30,11 +30,19 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { User } from '../../../core/models';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage';
|
||||
import { VoiceWorkspacePosition, VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
|
||||
import { ScreenShareQuality, ScreenShareStartOptions } from '../../../core/services/webrtc';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import {
|
||||
loadVoiceSettingsFromStorage,
|
||||
saveVoiceSettingsToStorage,
|
||||
VoiceSessionFacade,
|
||||
VoiceWorkspacePosition,
|
||||
VoiceWorkspaceService
|
||||
} from '../../../domains/voice-session';
|
||||
import { VoiceConnectionFacade } from '../../../domains/voice-connection';
|
||||
import {
|
||||
ScreenShareFacade,
|
||||
ScreenShareQuality,
|
||||
ScreenShareStartOptions
|
||||
} from '../../../domains/screen-share';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
@@ -79,10 +87,11 @@ export class ScreenShareWorkspaceComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
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 screenSharePlayback = inject(ScreenSharePlaybackService);
|
||||
private readonly voiceSession = inject(VoiceSessionService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private readonly remoteStreamRevision = signal(0);
|
||||
@@ -116,7 +125,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
||||
readonly isMuted = computed(() => this.webrtc.isMuted());
|
||||
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
||||
readonly isScreenSharing = computed(() => this.webrtc.isScreenSharing());
|
||||
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
||||
|
||||
readonly includeSystemAudio = signal(false);
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
@@ -167,7 +176,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
}
|
||||
|
||||
const shares: ScreenShareWorkspaceStreamItem[] = [];
|
||||
const localStream = this.webrtc.screenStream();
|
||||
const localStream = this.screenShare.screenStream();
|
||||
const localPeerKey = this.getUserPeerKey(me);
|
||||
|
||||
if (localStream && localPeerKey) {
|
||||
@@ -306,18 +315,18 @@ export class ScreenShareWorkspaceComponent {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.clearHeaderHideTimeout();
|
||||
this.cleanupObservedRemoteStreams();
|
||||
this.webrtc.syncRemoteScreenShareRequests([], false);
|
||||
this.screenShare.syncRemoteScreenShareRequests([], false);
|
||||
this.screenSharePlayback.teardownAll();
|
||||
});
|
||||
|
||||
this.webrtc.onRemoteStream
|
||||
this.screenShare.onRemoteStream
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(({ peerId }) => {
|
||||
this.observeRemoteStream(peerId);
|
||||
this.bumpRemoteStreamRevision();
|
||||
});
|
||||
|
||||
this.webrtc.onPeerDisconnected
|
||||
this.screenShare.onPeerDisconnected
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
||||
|
||||
@@ -363,7 +372,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
.filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey)
|
||||
));
|
||||
|
||||
this.webrtc.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
|
||||
this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
|
||||
|
||||
if (!shouldConnectRemoteShares) {
|
||||
this.screenSharePlayback.teardownAll();
|
||||
@@ -614,7 +623,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtc.stopScreenShare();
|
||||
this.screenShare.stopScreenShare();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -656,7 +665,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
});
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtc.stopScreenShare();
|
||||
this.screenShare.stopScreenShare();
|
||||
}
|
||||
|
||||
this.webrtc.disableVoice();
|
||||
@@ -773,7 +782,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
};
|
||||
|
||||
try {
|
||||
await this.webrtc.startScreenShare(options);
|
||||
await this.screenShare.startScreenShare(options);
|
||||
|
||||
this.voiceWorkspace.open(null);
|
||||
} catch {
|
||||
@@ -791,7 +800,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
);
|
||||
|
||||
for (const peerKey of peerKeys) {
|
||||
const stream = this.webrtc.getRemoteScreenShareStream(peerKey);
|
||||
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
||||
|
||||
if (stream && this.hasActiveVideo(stream)) {
|
||||
return { peerKey, stream };
|
||||
@@ -848,7 +857,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
}
|
||||
|
||||
private observeRemoteStream(peerKey: string): void {
|
||||
const stream = this.webrtc.getRemoteScreenShareStream(peerKey);
|
||||
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
||||
const existing = this.observedRemoteStreams.get(peerKey);
|
||||
|
||||
if (!stream) {
|
||||
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
effect,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
|
||||
import { ScreenShareFacade } from '../../../../domains/screen-share';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
|
||||
export interface PlaybackOptions {
|
||||
isConnected: boolean;
|
||||
@@ -33,7 +34,8 @@ interface PeerAudioPipeline {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoicePlaybackService {
|
||||
private webrtc = inject(WebRTCService);
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
|
||||
private peerPipelines = new Map<string, PeerAudioPipeline>();
|
||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||
@@ -50,20 +52,20 @@ export class VoicePlaybackService {
|
||||
this.loadPersistedVolumes();
|
||||
|
||||
effect(() => {
|
||||
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
|
||||
this.captureEchoSuppressed = this.screenShare.isScreenShareRemotePlaybackSuppressed();
|
||||
this.recalcAllGains();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput()
|
||||
this.temporaryOutputDeviceId = this.screenShare.forceDefaultRemotePlaybackOutput()
|
||||
? 'default'
|
||||
: null;
|
||||
|
||||
void this.applyEffectiveOutputDeviceToAllPipelines();
|
||||
});
|
||||
|
||||
this.webrtc.onRemoteStream.subscribe(({ peerId }) => {
|
||||
const voiceStream = this.webrtc.getRemoteVoiceStream(peerId);
|
||||
this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
|
||||
const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (!voiceStream) {
|
||||
this.removeRemoteAudio(peerId);
|
||||
@@ -73,14 +75,14 @@ export class VoicePlaybackService {
|
||||
this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions());
|
||||
});
|
||||
|
||||
this.webrtc.onVoiceConnected.subscribe(() => {
|
||||
this.voiceConnection.onVoiceConnected.subscribe(() => {
|
||||
const options = this.buildPlaybackOptions(true);
|
||||
|
||||
this.playPendingStreams(options);
|
||||
this.ensureAllRemoteStreamsPlaying(options);
|
||||
});
|
||||
|
||||
this.webrtc.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.voiceConnection.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.removeRemoteAudio(peerId);
|
||||
});
|
||||
}
|
||||
@@ -122,10 +124,10 @@ export class VoicePlaybackService {
|
||||
if (!options.isConnected)
|
||||
return;
|
||||
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
const peers = this.voiceConnection.getConnectedPeers();
|
||||
|
||||
for (const peerId of peers) {
|
||||
const stream = this.webrtc.getRemoteVoiceStream(peerId);
|
||||
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (stream && this.hasAudio(stream)) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
@@ -181,7 +183,7 @@ export class VoicePlaybackService {
|
||||
this.pendingRemoteStreams.clear();
|
||||
}
|
||||
|
||||
private buildPlaybackOptions(forceConnected = this.webrtc.isVoiceConnected()): PlaybackOptions {
|
||||
private buildPlaybackOptions(forceConnected = this.voiceConnection.isVoiceConnected()): PlaybackOptions {
|
||||
return {
|
||||
isConnected: forceConnected,
|
||||
outputVolume: this.masterVolume,
|
||||
|
||||
@@ -22,15 +22,14 @@ import {
|
||||
lucideHeadphones
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
||||
import { VoiceSessionFacade } from '../../../domains/voice-session';
|
||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage';
|
||||
import { ScreenShareQuality } from '../../../core/services/webrtc';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session';
|
||||
import {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
@@ -69,12 +68,13 @@ interface AudioDevice {
|
||||
templateUrl: './voice-controls.component.html'
|
||||
})
|
||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private voiceSessionService = inject(VoiceSessionService);
|
||||
private voiceActivity = inject(VoiceActivityService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private store = inject(Store);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -84,7 +84,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = this.webrtcService.isScreenSharing;
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
showSettings = signal(false);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
@@ -251,7 +251,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.screenShareService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Untrack local mic from voice-activity visualisation
|
||||
@@ -366,7 +366,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.screenShareService.stopScreenShare();
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
@@ -533,7 +533,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
try {
|
||||
await this.webrtcService.startScreenShare({
|
||||
await this.screenShareService.startScreenShare({
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../models/index';
|
||||
} from '../../core/models/index';
|
||||
|
||||
/** IndexedDB database name for the MetoYou application. */
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
@@ -259,15 +259,15 @@ export class BrowserDatabaseService {
|
||||
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||
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> {
|
||||
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[]> {
|
||||
return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
|
||||
}
|
||||
@@ -277,15 +277,11 @@ export class BrowserDatabaseService {
|
||||
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> {
|
||||
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
|
||||
STORE_ATTACHMENTS, 'messageId', messageId
|
||||
);
|
||||
|
||||
if (attachments.length === 0)
|
||||
return;
|
||||
|
||||
const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite');
|
||||
|
||||
for (const attachment of attachments) {
|
||||
@@ -295,7 +291,7 @@ export class BrowserDatabaseService {
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
/** Wipe every object store, removing all persisted data. */
|
||||
/** Wipe all persisted data in every object store. */
|
||||
async clearAllData(): Promise<void> {
|
||||
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
|
||||
|
||||
@@ -306,153 +302,140 @@ export class BrowserDatabaseService {
|
||||
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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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.onupgradeneeded = () => this.setupSchema(request.result);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an IndexedDB transaction on one or more stores. */
|
||||
private setupSchema(database: IDBDatabase): void {
|
||||
const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(messagesStore, 'roomId', 'roomId');
|
||||
this.ensureIndex(messagesStore, 'timestamp', 'timestamp');
|
||||
|
||||
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 ensureStore(
|
||||
database: IDBDatabase,
|
||||
name: string,
|
||||
options?: IDBObjectStoreParameters
|
||||
): IDBObjectStore {
|
||||
if (database.objectStoreNames.contains(name)) {
|
||||
return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name);
|
||||
}
|
||||
|
||||
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(
|
||||
stores: string | string[],
|
||||
mode: IDBTransactionMode = 'readonly'
|
||||
storeNames: string | string[],
|
||||
mode: IDBTransactionMode
|
||||
): IDBTransaction {
|
||||
return this.getDatabase().transaction(stores, mode);
|
||||
}
|
||||
|
||||
private getDatabase(): IDBDatabase {
|
||||
if (!this.database) {
|
||||
throw new Error('Browser database is not initialized');
|
||||
throw new Error('Database has not been initialized');
|
||||
}
|
||||
|
||||
return this.database;
|
||||
return this.database.transaction(storeNames, mode);
|
||||
}
|
||||
|
||||
/** Wrap a transaction's completion event as a Promise. */
|
||||
private awaitTransaction(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Retrieve a single record by primary key. */
|
||||
private get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName);
|
||||
private async put(storeName: string, value: unknown): Promise<void> {
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
|
||||
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.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Retrieve every record from an object store. */
|
||||
private getAll<T>(storeName: string): Promise<T[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName);
|
||||
private async getAll<T>(storeName: string): Promise<T[]> {
|
||||
const transaction = this.createTransaction(storeName, 'readonly');
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Retrieve all records from an index that match a key. */
|
||||
private getAllFromIndex<T>(
|
||||
private async getAllFromIndex<T>(
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
key: IDBValidKey
|
||||
query: IDBValidKey | IDBKeyRange
|
||||
): Promise<T[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName);
|
||||
const index = transaction.objectStore(storeName).index(indexName);
|
||||
const request = index.getAll(key);
|
||||
const transaction = this.createTransaction(storeName, 'readonly');
|
||||
const request = transaction.objectStore(storeName)
|
||||
.index(indexName)
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert or update a record in the given object store. */
|
||||
private put<T>(storeName: string, value: T): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
|
||||
transaction.objectStore(storeName).put(value);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a record by primary key. */
|
||||
private deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
private async deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
|
||||
transaction.objectStore(storeName).delete(key);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
private normaliseMessage(message: Message): Message {
|
||||
if (!message.isDeleted)
|
||||
return message;
|
||||
if (message.content === DELETED_MESSAGE_CONTENT) {
|
||||
return { ...message,
|
||||
reactions: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: DELETED_MESSAGE_CONTENT,
|
||||
reactions: []
|
||||
};
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
Reaction,
|
||||
BanEntry,
|
||||
ChatAttachmentMeta
|
||||
} from '../models/index';
|
||||
import { PlatformService } from './platform.service';
|
||||
} from '../../core/models/index';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { BrowserDatabaseService } from './browser-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
|
||||
* storage backend based on the runtime platform.
|
||||
*
|
||||
* - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
||||
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
|
||||
* - Electron -> SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
||||
* - Browser -> IndexedDB via {@link BrowserDatabaseService}.
|
||||
*
|
||||
* All consumers inject `DatabaseService` - the underlying storage engine
|
||||
* All consumers inject `DatabaseService`; the underlying storage engine
|
||||
* is selected automatically.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -1,35 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Message,
|
||||
User,
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../models/index';
|
||||
|
||||
/** CQRS API exposed by the Electron preload script via `contextBridge`. */
|
||||
interface ElectronAPI {
|
||||
command<T = unknown>(command: { type: string; payload: unknown }): Promise<T>;
|
||||
query<T = unknown>(query: { type: string; payload: unknown }): Promise<T>;
|
||||
}
|
||||
} from '../../core/models/index';
|
||||
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
||||
|
||||
/**
|
||||
* Database service for the Electron (desktop) runtime.
|
||||
*
|
||||
* 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
|
||||
* structured command/query objects through the unified `cqrs:command` and
|
||||
* `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.
|
||||
* The SQLite database is managed by TypeORM in the Electron main process.
|
||||
* This service is a thin CQRS IPC client that dispatches structured
|
||||
* command/query objects through the unified preload channels.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronDatabaseService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
/** Shorthand accessor for the preload-exposed CQRS API. */
|
||||
private get api(): ElectronAPI {
|
||||
// eslint-disable-next-line
|
||||
return (window as any).electronAPI as ElectronAPI;
|
||||
private get api(): ElectronApi {
|
||||
return this.electronBridge.requireApi();
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
*/
|
||||
import { Subject } from 'rxjs';
|
||||
import { ChatEvent } from '../../models';
|
||||
import { WebRTCLogger } from './webrtc-logger';
|
||||
import { PeerData } from './webrtc.types';
|
||||
import { ChatEvent } from '../../../core/models';
|
||||
import { LatencyProfile } from '../realtime.constants';
|
||||
import { PeerData } from '../realtime.types';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
import { NoiseReductionManager } from './noise-reduction.manager';
|
||||
import {
|
||||
TRACK_KIND_AUDIO,
|
||||
@@ -23,9 +24,8 @@ import {
|
||||
VOLUME_MAX,
|
||||
VOICE_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_VOICE_STATE,
|
||||
LatencyProfile
|
||||
} from './webrtc.constants';
|
||||
P2P_TYPE_VOICE_STATE
|
||||
} from '../realtime.constants';
|
||||
|
||||
/**
|
||||
* 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