diff --git a/.gitea/workflows/release-draft.yml b/.gitea/workflows/release-draft.yml index 5b22c74..adebc2a 100644 --- a/.gitea/workflows/release-draft.yml +++ b/.gitea/workflows/release-draft.yml @@ -67,8 +67,10 @@ jobs: - name: Build application run: | - npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js + npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js + cd toju-app npx ng build --configuration production --base-href='./' + cd .. npx --package typescript tsc -p tsconfig.electron.json cd server node ../tools/sync-server-build-version.js @@ -120,8 +122,10 @@ jobs: - name: Build application run: | - npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js + npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js + Push-Location "toju-app" npx ng build --configuration production --base-href='./' + Pop-Location npx --package typescript tsc -p tsconfig.electron.json Push-Location server node ../tools/sync-server-build-version.js diff --git a/.gitignore b/.gitignore index e0870eb..1df3392 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,9 @@ /tmp /out-tsc /bazel-out - +*.sqlite +*/architecture.md +/docs # Node /node_modules npm-debug.log @@ -51,3 +53,6 @@ Thumbs.db .certs/ /server/data/variables.json dist-server/* + +AGENTS.md +doc/** diff --git a/README.md b/README.md index 703e378..94fc0fa 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ + + + # Toju / Zoracord -Desktop chat app with three parts: +Desktop chat app with four parts: - `src/` Angular client - `electron/` desktop shell, IPC, and local database - `server/` directory server, join request API, and websocket events +- `website/` Toju website served at toju.app ## Install @@ -52,3 +56,64 @@ Inside `server/`: - `npm run dev` starts the server with reload - `npm run build` compiles to `dist/` - `npm run start` runs the compiled server + +# Images + + + +## Main Toju app Structure + +| Path | Description | +|------|-------------| +| `src/app/` | Main application root | +| `src/app/core/` | Core utilities, services, models | +| `src/app/domains/` | Domain-driven modules | +| `src/app/features/` | UI feature modules | +| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) | +| `src/app/shared/` | Shared UI components | +| `src/app/shared-kernel/` | Shared domain contracts & models | +| `src/app/store/` | Global state management | +| `src/assets/` | Static assets | +| `src/environments/` | Environment configs | + +--- + +### Domains + +| Path | Link | +|------|------| +| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) | +| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) | +| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) | +| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) | +| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) | +| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) | +| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) | +| Domains Root | [app/domains/README.md](src/app/domains/README.md) | + +--- + +### Infrastructure + +| Path | Link | +|------|------| +| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) | +| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) | + +--- + +### Shared Kernel + +| Path | Link | +|------|------| +| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) | + +--- + +### Entry Points + +| File | Link | +|------|------| +| Main | [main.ts](src/main.ts) | +| Index HTML | [index.html](src/index.html) | +| App Root | [app/app.ts](src/app/app.ts) | diff --git a/dev.sh b/dev.sh index 7eef7fa..3088327 100755 --- a/dev.sh +++ b/dev.sh @@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then "$DIR/generate-cert.sh" fi - NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key" + NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key" WAIT_URL="https://localhost:4200" HEALTH_URL="https://localhost:3001/api/health" export NODE_TLS_REJECT_UNAUTHORIZED=0 else - NG_SERVE="ng serve --host=0.0.0.0" + NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0" WAIT_URL="http://localhost:4200" HEALTH_URL="http://localhost:3001/api/health" fi diff --git a/electron/app/auto-start.ts b/electron/app/auto-start.ts index e29bcdb..6de1b3a 100644 --- a/electron/app/auto-start.ts +++ b/electron/app/auto-start.ts @@ -1,8 +1,13 @@ import { app } from 'electron'; import AutoLaunch from 'auto-launch'; +import * as fsp from 'fs/promises'; +import * as path from 'path'; import { readDesktopSettings } from '../desktop-settings'; let autoLauncher: AutoLaunch | null = null; +let autoLaunchPath = ''; + +const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U']; function resolveLaunchPath(): string { // AppImage runs from a temporary mount; APPIMAGE points to the real file path. @@ -13,15 +18,77 @@ function resolveLaunchPath(): string { return appImagePath || process.execPath; } +function escapeDesktopEntryExecArgument(argument: string): string { + const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1'); + + return /[\s"]/u.test(argument) + ? `"${escapedArgument}"` + : escapedArgument; +} + +function getLinuxAutoStartDesktopEntryPath(launchPath: string): string { + return path.join(app.getPath('home'), '.config', 'autostart', `${path.basename(launchPath)}.desktop`); +} + +function buildLinuxAutoStartExecLine(launchPath: string): string { + return `Exec=${[escapeDesktopEntryExecArgument(launchPath), ...LINUX_AUTO_START_ARGUMENTS].join(' ')}`; +} + +function buildLinuxAutoStartDesktopEntry(launchPath: string): string { + const appName = path.basename(launchPath); + + return [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + `Name=${appName}`, + `Comment=${appName}startup script`, + buildLinuxAutoStartExecLine(launchPath), + 'StartupNotify=false', + 'Terminal=false' + ].join('\n'); +} + +async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promise { + if (process.platform !== 'linux') { + return; + } + + const desktopEntryPath = getLinuxAutoStartDesktopEntryPath(launchPath); + const execLine = buildLinuxAutoStartExecLine(launchPath); + + let currentDesktopEntry = ''; + + try { + currentDesktopEntry = await fsp.readFile(desktopEntryPath, 'utf8'); + } catch { + // Create the desktop entry if auto-launch did not leave one behind. + } + + const nextDesktopEntry = currentDesktopEntry + ? /^Exec=.*$/m.test(currentDesktopEntry) + ? currentDesktopEntry.replace(/^Exec=.*$/m, execLine) + : `${currentDesktopEntry.trimEnd()}\n${execLine}\n` + : buildLinuxAutoStartDesktopEntry(launchPath); + + if (nextDesktopEntry === currentDesktopEntry) { + return; + } + + await fsp.mkdir(path.dirname(desktopEntryPath), { recursive: true }); + await fsp.writeFile(desktopEntryPath, nextDesktopEntry, 'utf8'); +} + function getAutoLauncher(): AutoLaunch | null { if (!app.isPackaged) { return null; } if (!autoLauncher) { + autoLaunchPath = resolveLaunchPath(); autoLauncher = new AutoLaunch({ name: app.getName(), - path: resolveLaunchPath() + path: autoLaunchPath }); } @@ -37,12 +104,16 @@ async function setAutoStartEnabled(enabled: boolean): Promise { const currentlyEnabled = await launcher.isEnabled(); - if (currentlyEnabled === enabled) { + if (!enabled && currentlyEnabled === enabled) { return; } if (enabled) { - await launcher.enable(); + if (!currentlyEnabled) { + await launcher.enable(); + } + + await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath); return; } diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts index 4c0c9b4..5e0ecbb 100644 --- a/electron/app/lifecycle.ts +++ b/electron/app/lifecycle.ts @@ -7,7 +7,13 @@ import { destroyDatabase, getDataSource } from '../db/database'; -import { createWindow, getDockIconPath } from '../window/create-window'; +import { + createWindow, + getDockIconPath, + getMainWindow, + prepareWindowForAppQuit, + showMainWindow +} from '../window/create-window'; import { setupCqrsHandlers, setupSystemHandlers, @@ -30,8 +36,13 @@ export function registerAppLifecycle(): void { await createWindow(); app.on('activate', () => { + if (getMainWindow()) { + void showMainWindow(); + return; + } + if (BrowserWindow.getAllWindows().length === 0) - createWindow(); + void createWindow(); }); }); @@ -41,6 +52,8 @@ export function registerAppLifecycle(): void { }); app.on('before-quit', async (event) => { + prepareWindowForAppQuit(); + if (getDataSource()?.isInitialized) { event.preventDefault(); shutdownDesktopUpdater(); diff --git a/electron/cqrs/queries/handlers/getMessagesSince.ts b/electron/cqrs/queries/handlers/getMessagesSince.ts new file mode 100644 index 0000000..e8a628c --- /dev/null +++ b/electron/cqrs/queries/handlers/getMessagesSince.ts @@ -0,0 +1,18 @@ +import { DataSource, MoreThan } from 'typeorm'; +import { MessageEntity } from '../../../entities'; +import { GetMessagesSinceQuery } from '../../types'; +import { rowToMessage } from '../../mappers'; + +export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) { + const repo = dataSource.getRepository(MessageEntity); + const { roomId, sinceTimestamp } = query.payload; + const rows = await repo.find({ + where: { + roomId, + timestamp: MoreThan(sinceTimestamp) + }, + order: { timestamp: 'ASC' } + }); + + return rows.map(rowToMessage); +} diff --git a/electron/cqrs/queries/index.ts b/electron/cqrs/queries/index.ts index da631bf..cdb5465 100644 --- a/electron/cqrs/queries/index.ts +++ b/electron/cqrs/queries/index.ts @@ -4,6 +4,7 @@ import { QueryTypeKey, Query, GetMessagesQuery, + GetMessagesSinceQuery, GetMessageByIdQuery, GetReactionsForMessageQuery, GetUserQuery, @@ -13,6 +14,7 @@ import { GetAttachmentsForMessageQuery } from '../types'; import { handleGetMessages } from './handlers/getMessages'; +import { handleGetMessagesSince } from './handlers/getMessagesSince'; import { handleGetMessageById } from './handlers/getMessageById'; import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage'; import { handleGetUser } from './handlers/getUser'; @@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments'; export const buildQueryHandlers = (dataSource: DataSource): Record Promise> => ({ [QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource), + [QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource), [QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource), [QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource), [QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource), diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index fc2f155..52f3c68 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType]; export const QueryType = { GetMessages: 'get-messages', + GetMessagesSince: 'get-messages-since', GetMessageById: 'get-message-by-id', GetReactionsForMessage: 'get-reactions-for-message', GetUser: 'get-user', @@ -160,6 +161,7 @@ export type Command = | ClearAllDataCommand; export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } +export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } } export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } } @@ -174,6 +176,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen export type Query = | GetMessagesQuery + | GetMessagesSinceQuery | GetMessageByIdQuery | GetReactionsForMessageQuery | GetUserQuery diff --git a/electron/desktop-settings.ts b/electron/desktop-settings.ts index a27c2d4..041248b 100644 --- a/electron/desktop-settings.ts +++ b/electron/desktop-settings.ts @@ -7,6 +7,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version'; export interface DesktopSettings { autoUpdateMode: AutoUpdateMode; autoStart: boolean; + closeToTray: boolean; hardwareAcceleration: boolean; manifestUrls: string[]; preferredVersion: string | null; @@ -21,6 +22,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings { const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { autoUpdateMode: 'auto', autoStart: true, + closeToTray: true, hardwareAcceleration: true, manifestUrls: [], preferredVersion: null, @@ -86,6 +88,9 @@ export function readDesktopSettings(): DesktopSettings { autoStart: typeof parsed.autoStart === 'boolean' ? parsed.autoStart : DEFAULT_DESKTOP_SETTINGS.autoStart, + closeToTray: typeof parsed.closeToTray === 'boolean' + ? parsed.closeToTray + : DEFAULT_DESKTOP_SETTINGS.closeToTray, vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean' ? parsed.vaapiVideoEncode : DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode, @@ -110,6 +115,9 @@ export function updateDesktopSettings(patch: Partial): DesktopS autoStart: typeof mergedSettings.autoStart === 'boolean' ? mergedSettings.autoStart : DEFAULT_DESKTOP_SETTINGS.autoStart, + closeToTray: typeof mergedSettings.closeToTray === 'boolean' + ? mergedSettings.closeToTray + : DEFAULT_DESKTOP_SETTINGS.closeToTray, hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' ? mergedSettings.hardwareAcceleration : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 9350c7f..8e07e39 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -4,6 +4,7 @@ import { desktopCapturer, dialog, ipcMain, + Notification, shell } from 'electron'; import * as fs from 'fs'; @@ -28,10 +29,16 @@ import { getDesktopUpdateState, handleDesktopSettingsChanged, restartToApplyUpdate, + readDesktopUpdateServerHealth, type DesktopUpdateServerContext } from '../update/desktop-updater'; import { consumePendingDeepLink } from '../app/deep-links'; import { synchronizeAutoStartSetting } from '../app/auto-start'; +import { + getMainWindow, + getWindowIconPath, + updateCloseToTraySetting +} from '../window/create-window'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; const FILE_CLIPBOARD_FORMATS = [ @@ -85,6 +92,12 @@ interface ClipboardFilePayload { path?: string; } +interface DesktopNotificationPayload { + body: string; + requestAttention?: boolean; + title: string; +} + function resolveLinuxDisplayServer(): string { if (process.platform !== 'linux') { return 'N/A'; @@ -315,8 +328,75 @@ export function setupSystemHandlers(): void { ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot()); + ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => { + const title = typeof payload?.title === 'string' ? payload.title.trim() : ''; + const body = typeof payload?.body === 'string' ? payload.body : ''; + const mainWindow = getMainWindow(); + + if (!title) { + return false; + } + + if (Notification.isSupported()) { + try { + const notification = new Notification({ + title, + body, + icon: getWindowIconPath(), + silent: true + }); + + notification.on('click', () => { + if (!mainWindow) { + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + + mainWindow.focus(); + }); + + notification.show(); + } catch { + // Ignore notification center failures and still attempt taskbar attention. + } + } + + if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) { + mainWindow.flashFrame(true); + } + + return true; + }); + + ipcMain.handle('request-window-attention', () => { + const mainWindow = getMainWindow(); + + if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) { + return false; + } + + mainWindow.flashFrame(true); + return true; + }); + + ipcMain.handle('clear-window-attention', () => { + getMainWindow()?.flashFrame(false); + return true; + }); + ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState()); + ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => { + return await readDesktopUpdateServerHealth(serverUrl); + }); + ipcMain.handle('configure-auto-update-context', async (_event, context: Partial) => { return await configureDesktopUpdaterContext(context); }); @@ -331,6 +411,7 @@ export function setupSystemHandlers(): void { const snapshot = updateDesktopSettings(patch); await synchronizeAutoStartSetting(snapshot.autoStart); + updateCloseToTraySetting(snapshot.closeToTray); await handleDesktopSettingsChanged(); return snapshot; }); diff --git a/electron/preload.ts b/electron/preload.ts index 6204189..f956dbe 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -5,6 +5,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monit const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended'; const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received'; +const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; export interface LinuxScreenShareAudioRoutingInfo { available: boolean; @@ -50,6 +51,12 @@ export interface DesktopUpdateServerContext { serverVersionStatus: DesktopUpdateServerVersionStatus; } +export interface DesktopUpdateServerHealthSnapshot { + manifestUrl: string | null; + serverVersion: string | null; + serverVersionStatus: DesktopUpdateServerVersionStatus; +} + export interface DesktopUpdateState { autoUpdateMode: 'auto' | 'off' | 'version'; availableVersions: string[]; @@ -84,6 +91,17 @@ export interface DesktopUpdateState { targetVersion: string | null; } +export interface DesktopNotificationPayload { + body: string; + requestAttention: boolean; + title: string; +} + +export interface WindowStateSnapshot { + isFocused: boolean; + isMinimized: boolean; +} + function readLinuxDisplayServer(): string { if (process.platform !== 'linux') { return 'N/A'; @@ -120,13 +138,19 @@ export interface ElectronAPI { getDesktopSettings: () => Promise<{ autoUpdateMode: 'auto' | 'off' | 'version'; autoStart: boolean; + closeToTray: boolean; hardwareAcceleration: boolean; manifestUrls: string[]; preferredVersion: string | null; runtimeHardwareAcceleration: boolean; restartRequired: boolean; }>; + showDesktopNotification: (payload: DesktopNotificationPayload) => Promise; + requestWindowAttention: () => Promise; + clearWindowAttention: () => Promise; + onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void; getAutoUpdateState: () => Promise; + getAutoUpdateServerHealth: (serverUrl: string) => Promise; configureAutoUpdateContext: (context: Partial) => Promise; checkForAppUpdates: () => Promise; restartToApplyUpdate: () => Promise; @@ -134,6 +158,7 @@ export interface ElectronAPI { setDesktopSettings: (patch: { autoUpdateMode?: 'auto' | 'off' | 'version'; autoStart?: boolean; + closeToTray?: boolean; hardwareAcceleration?: boolean; manifestUrls?: string[]; preferredVersion?: string | null; @@ -141,6 +166,7 @@ export interface ElectronAPI { }) => Promise<{ autoUpdateMode: 'auto' | 'off' | 'version'; autoStart: boolean; + closeToTray: boolean; hardwareAcceleration: boolean; manifestUrls: string[]; preferredVersion: string | null; @@ -206,7 +232,22 @@ const electronAPI: ElectronAPI = { getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'), getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'), + showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload), + requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'), + clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'), + onWindowStateChanged: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => { + listener(state); + }; + + ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener); + + return () => { + ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener); + }; + }, getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'), + getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl), configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context), checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'), restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'), diff --git a/electron/update/desktop-updater.ts b/electron/update/desktop-updater.ts index 1ee9303..58eccfe 100644 --- a/electron/update/desktop-updater.ts +++ b/electron/update/desktop-updater.ts @@ -18,6 +18,11 @@ interface ReleaseManifestEntry { version: string; } +interface ServerHealthResponse { + releaseManifestUrl?: string; + serverVersion?: string; +} + interface UpdateVersionInfo { version: string; } @@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext { serverVersionStatus: DesktopUpdateServerVersionStatus; } +export interface DesktopUpdateServerHealthSnapshot { + manifestUrl: string | null; + serverVersion: string | null; + serverVersionStatus: DesktopUpdateServerVersionStatus; +} + export interface DesktopUpdateState { autoUpdateMode: AutoUpdateMode; availableVersions: string[]; @@ -78,6 +89,8 @@ export interface DesktopUpdateState { export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; +const SERVER_HEALTH_TIMEOUT_MS = 5_000; + let currentCheckPromise: Promise | null = null; let currentContext: DesktopUpdateServerContext = { manifestUrls: [], @@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise { + const sanitizedServerUrl = sanitizeHttpUrl(serverUrl); + + if (!sanitizedServerUrl) { + return createUnavailableServerHealthSnapshot(); + } + + try { + const response = await net.fetch(`${sanitizedServerUrl}/api/health`, { + method: 'GET', + headers: { + accept: 'application/json' + }, + signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS) + }); + + if (!response.ok) { + return createUnavailableServerHealthSnapshot(); + } + + const payload = await response.json() as ServerHealthResponse; + const serverVersion = normalizeSemanticVersion(payload.serverVersion); + + return { + manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl), + serverVersion, + serverVersionStatus: serverVersion ? 'reported' : 'missing' + }; + } catch { + return createUnavailableServerHealthSnapshot(); + } +} + function formatManifestLoadErrors(errors: string[]): string { if (errors.length === 0) { return 'No valid release manifest could be loaded.'; @@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise { return desktopUpdateState; } +export async function readDesktopUpdateServerHealth( + serverUrl: string +): Promise { + return await loadServerHealth(serverUrl); +} + export function restartToApplyUpdate(): boolean { if (!desktopUpdateState.restartRequired) { return false; diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts index dacfd73..c4acabe 100644 --- a/electron/window/create-window.ts +++ b/electron/window/create-window.ts @@ -2,13 +2,21 @@ import { app, BrowserWindow, desktopCapturer, + Menu, session, - shell + shell, + Tray } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; +import { readDesktopSettings } from '../desktop-settings'; let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let closeToTrayEnabled = true; +let appQuitting = false; + +const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; function getAssetPath(...segments: string[]): string { const basePath = app.isPackaged @@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined { return getExistingAssetPath('macos', '1024x1024.png'); } +function getTrayIconPath(): string | undefined { + if (process.platform === 'win32') + return getExistingAssetPath('windows', 'icon.ico'); + + return getExistingAssetPath('icon.png'); +} + +export { getWindowIconPath }; + export function getMainWindow(): BrowserWindow | null { return mainWindow; } +function destroyTray(): void { + if (!tray) { + return; + } + + tray.destroy(); + tray = null; +} + +function requestAppQuit(): void { + prepareWindowForAppQuit(); + app.quit(); +} + +function ensureTray(): void { + if (tray) { + return; + } + + const trayIconPath = getTrayIconPath(); + + if (!trayIconPath) { + return; + } + + tray = new Tray(trayIconPath); + tray.setToolTip('MetoYou'); + tray.setContextMenu( + Menu.buildFromTemplate([ + { + label: 'Open MetoYou', + click: () => { + void showMainWindow(); + } + }, + { + type: 'separator' + }, + { + label: 'Close MetoYou', + click: () => { + requestAppQuit(); + } + } + ]) + ); + + tray.on('click', () => { + void showMainWindow(); + }); +} + +function hideWindowToTray(): void { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + mainWindow.hide(); + emitWindowState(); +} + +export function updateCloseToTraySetting(enabled: boolean): void { + closeToTrayEnabled = enabled; +} + +export function prepareWindowForAppQuit(): void { + appQuitting = true; + destroyTray(); +} + +export async function showMainWindow(): Promise { + if (!mainWindow || mainWindow.isDestroyed()) { + await createWindow(); + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + + mainWindow.focus(); + emitWindowState(); +} + +function emitWindowState(): void { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, { + isFocused: mainWindow.isFocused(), + isMinimized: mainWindow.isMinimized() + }); +} + export async function createWindow(): Promise { const windowIconPath = getWindowIconPath(); + closeToTrayEnabled = readDesktopSettings().closeToTray; + ensureTray(); + mainWindow = new BrowserWindow({ width: 1400, height: 900, @@ -105,10 +224,46 @@ export async function createWindow(): Promise { await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html')); } + mainWindow.on('close', (event) => { + if (appQuitting || !closeToTrayEnabled) { + return; + } + + event.preventDefault(); + hideWindowToTray(); + }); + mainWindow.on('closed', () => { mainWindow = null; }); + mainWindow.on('focus', () => { + mainWindow?.flashFrame(false); + emitWindowState(); + }); + + mainWindow.on('blur', () => { + emitWindowState(); + }); + + mainWindow.on('minimize', () => { + emitWindowState(); + }); + + mainWindow.on('restore', () => { + emitWindowState(); + }); + + mainWindow.on('show', () => { + emitWindowState(); + }); + + mainWindow.on('hide', () => { + emitWindowState(); + }); + + emitWindowState(); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; diff --git a/eslint.config.js b/eslint.config.js index a77ab53..c18212b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -199,7 +199,7 @@ module.exports = tseslint.config( }, // HTML template formatting rules (external Angular templates only) { - files: ['src/app/**/*.html'], + files: ['toju-app/src/app/**/*.html'], plugins: { 'no-dashes': noDashPlugin }, extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: { diff --git a/package.json b/package.json index 31b61e4..de36c4f 100644 --- a/package.json +++ b/package.json @@ -7,22 +7,22 @@ "homepage": "https://git.azaaxin.com/myxelium/Toju", "main": "dist/electron/main.js", "scripts": { - "ng": "ng", + "ng": "cd \"toju-app\" && ng", "prebuild": "npm run bundle:rnnoise", "prestart": "npm run bundle:rnnoise", - "bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js", - "start": "ng serve", - "build": "ng build", + "bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js", + "start": "cd \"toju-app\" && ng serve", + "build": "cd \"toju-app\" && ng build", "build:electron": "tsc -p tsconfig.electron.json", "build:all": "npm run build && npm run build:electron && cd server && npm run build", - "build:prod": "ng build --configuration production --base-href='./'", - "watch": "ng build --watch --configuration development", - "test": "ng test", + "build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'", + "watch": "cd \"toju-app\" && ng build --watch --configuration development", + "test": "cd \"toju-app\" && ng test", "server:build": "cd server && npm run build", "server:start": "cd server && npm start", "server:dev": "cd server && npm run dev", - "electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage", - "electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", + "electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage", + "electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", "electron:full": "./dev.sh", "electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", "migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js", @@ -40,8 +40,8 @@ "dev:app": "npm run electron:dev", "lint": "eslint .", "lint:fix": "npm run format && npm run sort:props && eslint . --fix", - "format": "prettier --write \"src/app/**/*.html\"", - "format:check": "prettier --check \"src/app/**/*.html\"", + "format": "prettier --write \"toju-app/src/app/**/*.html\"", + "format:check": "prettier --check \"toju-app/src/app/**/*.html\"", "release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux", "release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win", "release:manifest": "node tools/generate-release-manifest.js", diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 162c88c..f19b4c7 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/server/src/cqrs/commands/handlers/upsertServer.ts b/server/src/cqrs/commands/handlers/upsertServer.ts index 7a8dfc9..f45d23d 100644 --- a/server/src/cqrs/commands/handlers/upsertServer.ts +++ b/server/src/cqrs/commands/handlers/upsertServer.ts @@ -16,6 +16,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc maxUsers: server.maxUsers, currentUsers: server.currentUsers, tags: JSON.stringify(server.tags), + channels: JSON.stringify(server.channels ?? []), createdAt: server.createdAt, lastSeen: server.lastSeen }); diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts index 48e014b..78bae3a 100644 --- a/server/src/cqrs/mappers.ts +++ b/server/src/cqrs/mappers.ts @@ -3,10 +3,67 @@ import { ServerEntity } from '../entities/ServerEntity'; import { JoinRequestEntity } from '../entities/JoinRequestEntity'; import { AuthUserPayload, + ServerChannelPayload, ServerPayload, JoinRequestPayload } from './types'; +function channelNameKey(type: ServerChannelPayload['type'], name: string): string { + return `${type}:${name.toLocaleLowerCase()}`; +} + +function parseStringArray(raw: string | null | undefined): string[] { + try { + const parsed = JSON.parse(raw || '[]'); + + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === 'string') + : []; + } catch { + return []; + } +} + +function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] { + try { + const parsed = JSON.parse(raw || '[]'); + + if (!Array.isArray(parsed)) { + return []; + } + + const seenIds = new Set(); + const seenNames = new Set(); + + return parsed + .filter((channel): channel is Record => !!channel && typeof channel === 'object') + .map((channel, index) => { + const id = typeof channel.id === 'string' ? channel.id.trim() : ''; + const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : ''; + const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null; + const position = typeof channel.position === 'number' ? channel.position : index; + const nameKey = type ? channelNameKey(type, name) : ''; + + if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) { + return null; + } + + seenIds.add(id); + seenNames.add(nameKey); + + return { + id, + name, + type, + position + } satisfies ServerChannelPayload; + }) + .filter((channel): channel is ServerChannelPayload => !!channel); + } catch { + return []; + } +} + export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { return { id: row.id, @@ -29,7 +86,8 @@ export function rowToServer(row: ServerEntity): ServerPayload { isPrivate: !!row.isPrivate, maxUsers: row.maxUsers, currentUsers: row.currentUsers, - tags: JSON.parse(row.tags || '[]'), + tags: parseStringArray(row.tags), + channels: parseServerChannels(row.channels), createdAt: row.createdAt, lastSeen: row.lastSeen }; diff --git a/server/src/cqrs/types.ts b/server/src/cqrs/types.ts index 16be3f0..3087060 100644 --- a/server/src/cqrs/types.ts +++ b/server/src/cqrs/types.ts @@ -28,6 +28,15 @@ export interface AuthUserPayload { createdAt: number; } +export type ServerChannelType = 'text' | 'voice'; + +export interface ServerChannelPayload { + id: string; + name: string; + type: ServerChannelType; + position: number; +} + export interface ServerPayload { id: string; name: string; @@ -40,6 +49,7 @@ export interface ServerPayload { maxUsers: number; currentUsers: number; tags: string[]; + channels: ServerChannelPayload[]; createdAt: number; lastSeen: number; } diff --git a/server/src/entities/ServerEntity.ts b/server/src/entities/ServerEntity.ts index 8ea36cf..f978ab0 100644 --- a/server/src/entities/ServerEntity.ts +++ b/server/src/entities/ServerEntity.ts @@ -36,6 +36,9 @@ export class ServerEntity { @Column('text', { default: '[]' }) tags!: string; + @Column('text', { default: '[]' }) + channels!: string; + @Column('integer') createdAt!: number; diff --git a/server/src/migrations/1000000000000-InitialSchema.ts b/server/src/migrations/1000000000000-InitialSchema.ts index ef1d9c1..5b1eb25 100644 --- a/server/src/migrations/1000000000000-InitialSchema.ts +++ b/server/src/migrations/1000000000000-InitialSchema.ts @@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface { "maxUsers" INTEGER NOT NULL DEFAULT 0, "currentUsers" INTEGER NOT NULL DEFAULT 0, "tags" TEXT NOT NULL DEFAULT '[]', + "channels" TEXT NOT NULL DEFAULT '[]', "createdAt" INTEGER NOT NULL, "lastSeen" INTEGER NOT NULL ) diff --git a/server/src/migrations/1000000000002-ServerChannels.ts b/server/src/migrations/1000000000002-ServerChannels.ts new file mode 100644 index 0000000..fba903c --- /dev/null +++ b/server/src/migrations/1000000000002-ServerChannels.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ServerChannels1000000000002 implements MigrationInterface { + name = 'ServerChannels1000000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`); + } +} diff --git a/server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts b/server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts new file mode 100644 index 0000000..fc849e5 --- /dev/null +++ b/server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts @@ -0,0 +1,119 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +interface LegacyServerRow { + id: string; + channels: string | null; +} + +interface LegacyServerChannel { + id: string; + name: string; + type: 'text' | 'voice'; + position: number; +} + +function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] { + try { + const parsed = JSON.parse(raw || '[]'); + + if (!Array.isArray(parsed)) { + return []; + } + + const seenIds = new Set(); + const seenNames = new Set(); + + return parsed + .filter((channel): channel is Record => !!channel && typeof channel === 'object') + .map((channel, index) => { + const id = typeof channel.id === 'string' ? channel.id.trim() : ''; + const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : ''; + const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null; + const position = typeof channel.position === 'number' ? channel.position : index; + const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : ''; + + if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) { + return null; + } + + seenIds.add(id); + seenNames.add(nameKey); + + return { + id, + name, + type, + position + } satisfies LegacyServerChannel; + }) + .filter((channel): channel is LegacyServerChannel => !!channel); + } catch { + return []; + } +} + +function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean { + const hasTextGeneral = channels.some( + (channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general') + ); + const hasVoiceAfk = channels.some( + (channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk') + ); + const hasVoiceGeneral = channels.some( + (channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general') + ); + + return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral; +} + +function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] { + if (!shouldRestoreLegacyVoiceGeneral(channels)) { + return channels; + } + + const textChannels = channels.filter((channel) => channel.type === 'text'); + const voiceChannels = channels.filter((channel) => channel.type === 'voice'); + const repairedVoiceChannels = [ + { + id: 'vc-general', + name: 'General', + type: 'voice' as const, + position: 0 + }, + ...voiceChannels + ].map((channel, index) => ({ + ...channel, + position: index + })); + + return [ + ...textChannels, + ...repairedVoiceChannels + ]; +} + +export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface { + name = 'RepairLegacyVoiceChannels1000000000003'; + + public async up(queryRunner: QueryRunner): Promise { + const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[]; + + for (const row of rows) { + const channels = normalizeLegacyChannels(row.channels); + const repaired = repairLegacyVoiceChannels(channels); + + if (JSON.stringify(repaired) === JSON.stringify(channels)) { + continue; + } + + await queryRunner.query( + `UPDATE "servers" SET "channels" = ? WHERE "id" = ?`, + [JSON.stringify(repaired), row.id] + ); + } + } + + public async down(_queryRunner: QueryRunner): Promise { + // Forward-only data repair migration. + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index e44856e..4614762 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -1,7 +1,11 @@ import { InitialSchema1000000000000 } from './1000000000000-InitialSchema'; import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl'; +import { ServerChannels1000000000002 } from './1000000000002-ServerChannels'; +import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels'; export const serverMigrations = [ InitialSchema1000000000000, - ServerAccessControl1000000000001 + ServerAccessControl1000000000001, + ServerChannels1000000000002, + RepairLegacyVoiceChannels1000000000003 ]; diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 8a6fbf0..ced8cf6 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -1,6 +1,9 @@ import { Response, Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import { ServerPayload } from '../cqrs/types'; +import { + ServerChannelPayload, + ServerPayload +} from '../cqrs/types'; import { getAllPublicServers, getServerById, @@ -34,10 +37,51 @@ function normalizeRole(role: unknown): string | null { return typeof role === 'string' ? role.trim().toLowerCase() : null; } +function channelNameKey(type: ServerChannelPayload['type'], name: string): string { + return `${type}:${name.toLocaleLowerCase()}`; +} + function isAllowedRole(role: string | null, allowedRoles: string[]): boolean { return !!role && allowedRoles.includes(role); } +function normalizeServerChannels(value: unknown): ServerChannelPayload[] { + if (!Array.isArray(value)) { + return []; + } + + const seen = new Set(); + const seenNames = new Set(); + const channels: ServerChannelPayload[] = []; + + for (const [index, channel] of value.entries()) { + if (!channel || typeof channel !== 'object') { + continue; + } + + const id = typeof channel.id === 'string' ? channel.id.trim() : ''; + const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : ''; + const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null; + const position = typeof channel.position === 'number' ? channel.position : index; + const nameKey = type ? channelNameKey(type, name) : ''; + + if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) { + continue; + } + + seen.add(id); + seenNames.add(nameKey); + channels.push({ + id, + name, + type, + position + }); + } + + return channels; +} + async function enrichServer(server: ServerPayload, sourceUrl?: string) { const owner = await getUserById(server.ownerId); const { passwordHash, ...publicServer } = server; @@ -124,7 +168,8 @@ router.post('/', async (req, res) => { isPrivate, maxUsers, password, - tags + tags, + channels } = req.body; if (!name || !ownerId || !ownerPublicKey) @@ -143,6 +188,7 @@ router.post('/', async (req, res) => { maxUsers: maxUsers ?? 0, currentUsers: 0, tags: tags ?? [], + channels: normalizeServerChannels(channels), createdAt: Date.now(), lastSeen: Date.now() }; @@ -161,6 +207,7 @@ router.put('/:id', async (req, res) => { password, hasPassword: _ignoredHasPassword, passwordHash: _ignoredPasswordHash, + channels, ...updates } = req.body; const existing = await getServerById(id); @@ -178,10 +225,12 @@ router.put('/:id', async (req, res) => { } const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password'); + const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels'); const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null); const server: ServerPayload = { ...existing, ...updates, + channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels, hasPassword: !!nextPasswordHash, passwordHash: nextPasswordHash, lastSeen: Date.now() diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index aedb682..69b6cb7 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -134,11 +134,15 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void { function handleTyping(user: ConnectedUser, message: WsMessage): void { const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; + const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() + ? message['channelId'].trim() + : 'general'; if (typingSid && user.serverIds.has(typingSid)) { broadcastToServer(typingSid, { type: 'user_typing', serverId: typingSid, + channelId, oderId: user.oderId, displayName: user.displayName }, user.oderId); diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts deleted file mode 100644 index 0bff1bb..0000000 --- a/src/app/core/models/index.ts +++ /dev/null @@ -1,320 +0,0 @@ -export type UserStatus = 'online' | 'away' | 'busy' | 'offline'; - -export type UserRole = 'host' | 'admin' | 'moderator' | 'member'; - -export type ChannelType = 'text' | 'voice'; - -export const DELETED_MESSAGE_CONTENT = '[Message deleted]'; - -export interface User { - id: string; - oderId: string; - username: string; - displayName: string; - avatarUrl?: string; - status: UserStatus; - role: UserRole; - joinedAt: number; - peerId?: string; - isOnline?: boolean; - isAdmin?: boolean; - isRoomOwner?: boolean; - voiceState?: VoiceState; - screenShareState?: ScreenShareState; -} - -export interface RoomMember { - id: string; - oderId?: string; - username: string; - displayName: string; - avatarUrl?: string; - role: UserRole; - joinedAt: number; - lastSeenAt: number; -} - -export interface Channel { - id: string; - name: string; - type: ChannelType; - position: number; -} - -export interface Message { - id: string; - roomId: string; - channelId?: string; - senderId: string; - senderName: string; - content: string; - timestamp: number; - editedAt?: number; - reactions: Reaction[]; - isDeleted: boolean; - replyToId?: string; -} - -export interface Reaction { - id: string; - messageId: string; - oderId: string; - userId: string; - emoji: string; - timestamp: number; -} - -export interface Room { - id: string; - name: string; - description?: string; - topic?: string; - hostId: string; - password?: string; - hasPassword?: boolean; - isPrivate: boolean; - createdAt: number; - userCount: number; - maxUsers?: number; - icon?: string; - iconUpdatedAt?: number; - permissions?: RoomPermissions; - channels?: Channel[]; - members?: RoomMember[]; - sourceId?: string; - sourceName?: string; - sourceUrl?: string; -} - -export interface RoomSettings { - name: string; - description?: string; - topic?: string; - isPrivate: boolean; - password?: string; - hasPassword?: boolean; - maxUsers?: number; - rules?: string[]; -} - -export interface RoomPermissions { - adminsManageRooms?: boolean; - moderatorsManageRooms?: boolean; - adminsManageIcon?: boolean; - moderatorsManageIcon?: boolean; - allowVoice?: boolean; - allowScreenShare?: boolean; - allowFileUploads?: boolean; - slowModeInterval?: number; -} - -export interface BanEntry { - oderId: string; - userId: string; - roomId: string; - bannedBy: string; - displayName?: string; - reason?: string; - expiresAt?: number; - timestamp: number; -} - -export interface PeerConnection { - peerId: string; - userId: string; - status: 'connecting' | 'connected' | 'disconnected' | 'failed'; - dataChannel?: RTCDataChannel; - connection?: RTCPeerConnection; -} - -export interface VoiceState { - isConnected: boolean; - isMuted: boolean; - isDeafened: boolean; - isSpeaking: boolean; - isMutedByAdmin?: boolean; - volume?: number; - roomId?: string; - serverId?: string; -} - -export interface ScreenShareState { - isSharing: boolean; - streamId?: string; - sourceId?: string; - sourceName?: string; -} - -export type SignalingMessageType = - | 'offer' - | 'answer' - | 'ice-candidate' - | 'join' - | 'leave' - | 'chat' - | 'state-sync' - | 'kick' - | 'ban' - | 'host-change' - | 'room-update'; - -export interface SignalingMessage { - type: SignalingMessageType; - from: string; - to?: string; - payload: unknown; - timestamp: number; -} - -export type ChatEventType = - | 'message' - | 'chat-message' - | 'edit' - | 'message-edited' - | 'delete' - | 'message-deleted' - | 'reaction' - | 'reaction-added' - | 'reaction-removed' - | 'kick' - | 'ban' - | 'room-deleted' - | 'host-change' - | 'room-settings-update' - | 'voice-state' - | 'chat-inventory-request' - | 'chat-inventory' - | 'chat-sync-request-ids' - | 'chat-sync-batch' - | 'chat-sync-summary' - | 'chat-sync-request' - | 'chat-sync-full' - | 'file-announce' - | 'file-chunk' - | 'file-request' - | 'file-cancel' - | 'file-not-found' - | 'member-roster-request' - | 'member-roster' - | 'member-leave' - | 'voice-state-request' - | 'state-request' - | 'screen-state' - | 'screen-share-request' - | 'screen-share-stop' - | 'role-change' - | 'room-permissions-update' - | 'server-icon-summary' - | 'server-icon-request' - | 'server-icon-full' - | 'server-icon-update' - | 'server-state-request' - | 'server-state-full' - | 'unban' - | 'channels-update'; - -export interface ChatInventoryItem { - id: string; - ts: number; - rc: number; - ac?: number; -} - -export interface ChatAttachmentAnnouncement { - id: string; - filename: string; - size: number; - mime: string; - isImage: boolean; - uploaderPeerId?: string; -} - -export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement { - messageId: string; - filePath?: string; - savedPath?: string; -} - -/** Optional fields depend on `type`. */ -export interface ChatEvent { - type: ChatEventType; - fromPeerId?: string; - messageId?: string; - message?: Message; - reaction?: Reaction; - data?: string | Partial; - timestamp?: number; - targetUserId?: string; - roomId?: string; - items?: ChatInventoryItem[]; - ids?: string[]; - messages?: Message[]; - attachments?: Record; - total?: number; - index?: number; - count?: number; - lastUpdated?: number; - file?: ChatAttachmentAnnouncement; - fileId?: string; - hostId?: string; - hostOderId?: string; - previousHostId?: string; - previousHostOderId?: string; - kickedBy?: string; - bannedBy?: string; - content?: string; - editedAt?: number; - deletedAt?: number; - deletedBy?: string; - oderId?: string; - displayName?: string; - emoji?: string; - reason?: string; - settings?: Partial; - permissions?: Partial; - voiceState?: Partial; - isScreenSharing?: boolean; - icon?: string; - iconUpdatedAt?: number; - role?: UserRole; - room?: Partial; - channels?: Channel[]; - members?: RoomMember[]; - ban?: BanEntry; - bans?: BanEntry[]; - banOderId?: string; - expiresAt?: number; -} - -export interface ServerInfo { - id: string; - name: string; - description?: string; - topic?: string; - hostName: string; - ownerId?: string; - ownerName?: string; - ownerPublicKey?: string; - userCount: number; - maxUsers: number; - hasPassword?: boolean; - isPrivate: boolean; - tags?: string[]; - createdAt: number; - sourceId?: string; - sourceName?: string; - sourceUrl?: string; -} - -export interface JoinRequest { - roomId: string; - userId: string; - username: string; -} - -export interface AppState { - currentUser: User | null; - currentRoom: Room | null; - isConnecting: boolean; - error: string | null; -} diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts deleted file mode 100644 index 74c8b33..0000000 --- a/src/app/core/services/attachment.service.ts +++ /dev/null @@ -1,1378 +0,0 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */ -import { - Injectable, - inject, - signal, - effect -} from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { take } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; -import { WebRTCService } from './webrtc.service'; -import { Store } from '@ngrx/store'; -import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; -import { DatabaseService } from './database.service'; -import { recordDebugNetworkFileChunk } from './debug-network-metrics.service'; -import { ROOM_URL_PATTERN } from '../constants'; -import type { - ChatAttachmentAnnouncement, - ChatAttachmentMeta, - ChatEvent -} from '../models/index'; - -/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ -const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB - -/** 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 -/** - * EWMA smoothing weight for the *previous* speed estimate. - * The complementary weight (1 − this value) is applied to the - * instantaneous measurement. - */ -const EWMA_PREVIOUS_WEIGHT = 0.7; -const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT; -/** Fallback MIME type when none is provided by the sender. */ -const DEFAULT_MIME_TYPE = 'application/octet-stream'; -/** localStorage key used by the legacy attachment store (migration target). */ -const LEGACY_STORAGE_KEY = 'metoyou_attachments'; -/** User-facing error when no peers are available for a request. */ -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. */ -const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.'; - -/** - * Metadata describing a file attachment linked to a chat message. - */ -export type AttachmentMeta = ChatAttachmentMeta; - -/** - * Runtime representation of an attachment including download - * progress and blob URL state. - */ -export interface Attachment extends AttachmentMeta { - /** Whether the file content is available locally (blob URL set). */ - available: boolean; - /** Object URL for in-browser rendering / download. */ - objectUrl?: string; - /** Number of bytes received so far (during chunked download). */ - receivedBytes?: number; - /** Estimated download speed (bytes / second), EWMA-smoothed. */ - speedBps?: number; - /** Epoch ms when the download started. */ - startedAtMs?: number; - /** Epoch ms of the most recent chunk received. */ - lastUpdateMs?: number; - /** User-facing request failure shown in the attachment card. */ - requestError?: string; -} - -type FileAnnounceEvent = ChatEvent & { - type: 'file-announce'; - messageId: string; - file: ChatAttachmentAnnouncement; -}; - -type FileChunkEvent = ChatEvent & { - type: 'file-chunk'; - messageId: string; - fileId: string; - index: number; - total: number; - data: string; - fromPeerId?: string; -}; - -type FileRequestEvent = ChatEvent & { - type: 'file-request'; - messageId: string; - fileId: string; - fromPeerId?: string; -}; - -type FileCancelEvent = ChatEvent & { - type: 'file-cancel'; - messageId: string; - fileId: string; - fromPeerId?: string; -}; - -type FileNotFoundEvent = ChatEvent & { - type: 'file-not-found'; - messageId: string; - fileId: string; -}; - -type FileAnnouncePayload = Pick; -interface FileChunkPayload { - messageId?: string; - fileId?: string; - fromPeerId?: string; - index?: number; - total?: number; - data?: ChatEvent['data']; -} -type FileRequestPayload = Pick; -type FileCancelPayload = Pick; -type FileNotFoundPayload = Pick; - -interface AttachmentElectronApi { - getAppDataPath?: () => Promise; - fileExists?: (filePath: string) => Promise; - readFile?: (filePath: string) => Promise; - deleteFile?: (filePath: string) => Promise; - ensureDir?: (dirPath: string) => Promise; - writeFile?: (filePath: string, data: string) => Promise; -} - -type ElectronWindow = Window & { - electronAPI?: AttachmentElectronApi; -}; - -type LocalFileWithPath = File & { - path?: string; -}; - -/** - * Manages peer-to-peer file transfer, local persistence, and - * in-memory caching of file attachments linked to chat messages. - * - * Files are announced to peers via a `file-announce` event and - * transferred using a chunked base-64 protocol over WebRTC data - * channels. On Electron, files under {@link MAX_AUTO_SAVE_SIZE_BYTES} - * are automatically persisted to the app-data directory. - */ -@Injectable({ providedIn: 'root' }) -export class AttachmentService { - private readonly webrtc = inject(WebRTCService); - private readonly ngrxStore = inject(Store); - private readonly database = inject(DatabaseService); - private readonly router = inject(Router); - - /** Primary index: `messageId → Attachment[]`. */ - private attachmentsByMessage = new Map(); - /** Runtime cache of `messageId → roomId` for attachment gating. */ - private messageRoomIds = new Map(); - /** Room currently being watched in the router, or `null` outside room routes. */ - private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); - - /** Incremented on every mutation so signal consumers re-render. */ - updated = signal(0); - - /** - * In-memory map of original `File` objects retained by the uploader - * so that file-request handlers can stream them on demand. - * Key format: `"messageId:fileId"`. - */ - private originalFiles = new Map(); - - /** Set of `"messageId:fileId:peerId"` keys representing cancelled transfers. */ - private cancelledTransfers = new Set(); - - /** - * Map of `"messageId:fileId" → Set` tracking which peers - * have already been asked for a particular file. - */ - private pendingRequests = new Map>(); - - /** - * In-flight chunk assembly buffers. - * `"messageId:fileId" → ArrayBuffer[]` (indexed by chunk ordinal). - */ - private chunkBuffers = new Map(); - - /** - * Number of chunks received for each in-flight transfer. - * `"messageId:fileId" → number`. - */ - private chunkCounts = new Map(); - - /** Whether the initial DB load has been performed. */ - private isDatabaseInitialised = false; - - constructor() { - effect(() => { - if (this.database.isReady() && !this.isDatabaseInitialised) { - this.isDatabaseInitialised = true; - this.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); - } - }); - } - - private getElectronApi(): AttachmentElectronApi | undefined { - return (window as ElectronWindow).electronAPI; - } - - /** Return the attachment list for a given message. */ - getForMessage(messageId: string): Attachment[] { - return this.attachmentsByMessage.get(messageId) ?? []; - } - - /** Cache the room that owns a message so background downloads can be gated by the watched server. */ - rememberMessageRoom(messageId: string, roomId: string): void { - if (!messageId || !roomId) - return; - - this.messageRoomIds.set(messageId, roomId); - } - - /** Queue best-effort auto-download checks for a message's eligible attachments. */ - queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void { - void this.requestAutoDownloadsForMessage(messageId, attachmentId); - } - - /** Auto-request eligible missing attachments for the currently watched room. */ - async requestAutoDownloadsForRoom(roomId: string): Promise { - 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.rememberMessageRoom(message.id, message.roomId); - await this.requestAutoDownloadsForMessage(message.id); - } - - return; - } - - for (const [messageId] of this.attachmentsByMessage) { - const attachmentRoomId = await this.resolveMessageRoomId(messageId); - - if (attachmentRoomId === roomId) { - await this.requestAutoDownloadsForMessage(messageId); - } - } - } - - /** Remove every attachment associated with a message. */ - async deleteForMessage(messageId: string): Promise { - const attachments = this.attachmentsByMessage.get(messageId) ?? []; - const hadCachedAttachments = attachments.length > 0 || this.attachmentsByMessage.has(messageId); - const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId); - const savedPathsToDelete = new Set(); - - 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.attachmentsByMessage.delete(messageId); - this.messageRoomIds.delete(messageId); - this.clearMessageScopedState(messageId); - - if (hadCachedAttachments) { - this.touch(); - } - - if (this.database.isReady()) { - await this.database.deleteAttachmentsForMessage(messageId); - } - - for (const diskPath of savedPathsToDelete) { - await this.deleteSavedFile(diskPath); - } - } - - /** - * Build a map of minimal attachment metadata for a set of message IDs. - * Used during inventory-based message synchronisation so that peers - * learn about attachments without transferring file content. - * - * @param messageIds - Messages to collect metadata for. - * @returns Record keyed by messageId whose values are arrays of - * {@link AttachmentMeta} (local paths are scrubbed). - */ - getAttachmentMetasForMessages( - messageIds: string[] - ): Record { - const result: Record = {}; - - for (const messageId of messageIds) { - const attachments = this.attachmentsByMessage.get(messageId); - - if (attachments && 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, // never share local paths - savedPath: undefined // never share local paths - })); - } - } - - return result; - } - - /** - * Register attachment metadata received via message sync - * (content is not yet available - only metadata). - * - * @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer. - */ - registerSyncedAttachments( - attachmentMap: Record, - messageRoomIds?: Record - ): void { - if (messageRoomIds) { - for (const [messageId, roomId] of Object.entries(messageRoomIds)) { - this.rememberMessageRoom(messageId, roomId); - } - } - - const newAttachments: Attachment[] = []; - - for (const [messageId, metas] of Object.entries(attachmentMap)) { - const existing = this.attachmentsByMessage.get(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); - } - } - - if (existing.length > 0) { - this.attachmentsByMessage.set(messageId, existing); - } - } - - if (newAttachments.length > 0) { - this.touch(); - - for (const attachment of newAttachments) { - void this.persistAttachmentMeta(attachment); - this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id); - } - } - } - - /** - * Request a file from any connected peer that might have it. - * Automatically cycles through all connected peers if the first - * one does not have the file. - * - * @param messageId - Parent message. - * @param attachment - Attachment to request. - */ - 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.touch(); - console.warn('[Attachments] No connected peers to request file from'); - return; - } - - if (clearedRequestError) - this.touch(); - - const requestKey = this.buildRequestKey(messageId, attachment.id); - - this.pendingRequests.set(requestKey, new Set()); - this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId); - } - - /** - * Handle a `file-not-found` response - try the next available peer. - */ - handleFileNotFound(payload: FileNotFoundPayload): void { - const { messageId, fileId } = payload; - - if (!messageId || !fileId) - return; - - const attachments = this.attachmentsByMessage.get(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.touch(); - } - } - - /** - * Alias for {@link requestFromAnyPeer}. - * Convenience wrapper for image-specific call-sites. - */ - requestImageFromAnyPeer(messageId: string, attachment: Attachment): void { - this.requestFromAnyPeer(messageId, attachment); - } - - /** Alias for {@link requestFromAnyPeer}. */ - requestFile(messageId: string, attachment: Attachment): void { - this.requestFromAnyPeer(messageId, attachment); - } - - /** - * Announce and optionally stream files attached to a newly sent - * message to all connected peers. - * - * 1. Each file is assigned a UUID. - * 2. A `file-announce` event is broadcast to peers. - * 3. Peers watching the message's server can request any - * auto-download-eligible media on demand. - * - * @param messageId - ID of the parent message. - * @param files - Array of user-selected `File` objects. - * @param uploaderPeerId - Peer ID of the uploader (used by receivers - * to prefer the original source when requesting content). - */ - async publishAttachments( - messageId: string, - files: File[], - uploaderPeerId?: string - ): Promise { - const attachments: Attachment[] = []; - - for (const file of files) { - const fileId = uuidv4(); - const attachment: Attachment = { - id: fileId, - messageId, - filename: file.name, - size: file.size, - mime: file.type || DEFAULT_MIME_TYPE, - isImage: file.type.startsWith('image/'), - uploaderPeerId, - filePath: (file as LocalFileWithPath).path, - available: false - }; - - attachments.push(attachment); - - // Retain the original File so we can serve file-request later - this.originalFiles.set(`${messageId}:${fileId}`, file); - - // Make the file immediately visible to the uploader - try { - attachment.objectUrl = URL.createObjectURL(file); - attachment.available = true; - } catch { /* non-critical */ } - - // Auto-save small files to Electron disk cache - if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { - void this.saveFileToDisk(attachment, file); - } - - // Broadcast metadata to peers - 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.attachmentsByMessage.get(messageId) ?? []; - - this.attachmentsByMessage.set(messageId, [...existingList, ...attachments]); - this.touch(); - - for (const attachment of attachments) { - void this.persistAttachmentMeta(attachment); - } - } - - /** Handle a `file-announce` event from a peer. */ - handleFileAnnounce(payload: FileAnnouncePayload): void { - const { messageId, file } = payload; - - if (!messageId || !file) - return; - - const list = this.attachmentsByMessage.get(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.attachmentsByMessage.set(messageId, list); - this.touch(); - void this.persistAttachmentMeta(attachment); - this.queueAutoDownloadsForMessage(messageId, attachment.id); - } - - /** - * Handle an incoming `file-chunk` event. - * - * Chunks are collected in {@link chunkBuffers} until the total - * expected count is reached, at which point the buffers are - * assembled into a Blob and an object URL is created. - */ - 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.attachmentsByMessage.get(messageId) ?? []; - const attachment = list.find((entry) => entry.id === fileId); - - if (!attachment) - return; - - const decodedBytes = this.base64ToUint8Array(data); - const assemblyKey = `${messageId}:${fileId}`; - const requestKey = this.buildRequestKey(messageId, fileId); - - this.pendingRequests.delete(requestKey); - this.clearAttachmentRequestError(attachment); - - // Initialise assembly buffer on first chunk - let chunkBuffer = this.chunkBuffers.get(assemblyKey); - - if (!chunkBuffer) { - chunkBuffer = new Array(total); - this.chunkBuffers.set(assemblyKey, chunkBuffer); - this.chunkCounts.set(assemblyKey, 0); - } - - // Store the chunk (idempotent: ignore duplicate indices) - if (!chunkBuffer[index]) { - chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer; - this.chunkCounts.set(assemblyKey, (this.chunkCounts.get(assemblyKey) ?? 0) + 1); - } - - // Update progress stats - 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 = - EWMA_PREVIOUS_WEIGHT * previousSpeed + - EWMA_CURRENT_WEIGHT * instantaneousBps; - - attachment.lastUpdateMs = now; - - this.touch(); // trigger UI update for progress bars - - // Check if assembly is complete - const receivedChunkCount = this.chunkCounts.get(assemblyKey) ?? 0; - - if (receivedChunkCount === total || (attachment.receivedBytes ?? 0) >= attachment.size) { - const completeBuffer = this.chunkBuffers.get(assemblyKey); - - if (completeBuffer && completeBuffer.every((part) => part instanceof ArrayBuffer)) { - const blob = new Blob(completeBuffer, { type: attachment.mime }); - - attachment.available = true; - attachment.objectUrl = URL.createObjectURL(blob); - - if (this.shouldPersistDownloadedAttachment(attachment)) { - void this.saveFileToDisk(attachment, blob); - } - - // Clean up assembly state - this.chunkBuffers.delete(assemblyKey); - this.chunkCounts.delete(assemblyKey); - this.touch(); - void this.persistAttachmentMeta(attachment); - } - } - } - - /** - * Handle an incoming `file-request` from a peer by streaming the - * file content if available locally. - * - * Lookup order: - * 1. In-memory original (`originalFiles` map). - * 2. Electron `filePath` (uploader's original on disk). - * 3. Electron `savedPath` (disk-cache copy). - * 4. Electron disk-cache by room name (backward compat). - * 5. In-memory object-URL blob (browser fallback). - * - * If none of these sources has the file, a `file-not-found` - * message is sent so the requester can try another peer. - */ - async handleFileRequest(payload: FileRequestPayload): Promise { - const { messageId, fileId, fromPeerId } = payload; - - if (!messageId || !fileId || !fromPeerId) - return; - - // 1. In-memory original - const exactKey = `${messageId}:${fileId}`; - - let originalFile = this.originalFiles.get(exactKey); - - // 1b. Fallback: search by fileId suffix (handles rare messageId drift) - if (!originalFile) { - for (const [key, file] of this.originalFiles) { - if (key.endsWith(`:${fileId}`)) { - originalFile = file; - break; - } - } - } - - if (originalFile) { - await this.streamFileToPeer(fromPeerId, messageId, fileId, originalFile); - return; - } - - const list = this.attachmentsByMessage.get(messageId) ?? []; - const attachment = list.find((entry) => entry.id === fileId); - const electronApi = this.getElectronApi(); - - // 2. Electron filePath - if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) { - try { - if (await electronApi.fileExists(attachment.filePath)) { - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.filePath); - return; - } - } catch { /* fall through */ } - } - - // 3. Electron savedPath - if (attachment?.savedPath && electronApi?.fileExists && electronApi?.readFile) { - try { - if (await electronApi.fileExists(attachment.savedPath)) { - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.savedPath); - return; - } - } catch { /* fall through */ } - } - - // 3b. Disk cache by room name (backward compatibility) - if (attachment?.isImage && electronApi?.getAppDataPath && electronApi?.fileExists && electronApi?.readFile) { - try { - const appDataPath = await electronApi.getAppDataPath(); - - if (appDataPath) { - const roomName = await this.resolveCurrentRoomName(); - const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; - const diskPath = `${appDataPath}/server/${sanitisedRoom}/image/${attachment.filename}`; - - if (await electronApi.fileExists(diskPath)) { - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, diskPath); - return; - } - } - } catch { /* fall through */ } - } - - // 4. In-memory blob - 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.streamFileToPeer(fromPeerId, messageId, fileId, file); - return; - } catch { /* fall through */ } - } - - // 5. File not available locally - const fileNotFoundEvent: FileNotFoundEvent = { - type: 'file-not-found', - messageId, - fileId - }; - - this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent); - } - - /** - * Cancel an in-progress download from the requester side. - * Resets local assembly state and notifies the uploader to stop. - */ - cancelRequest(messageId: string, attachment: Attachment): void { - const targetPeerId = attachment.uploaderPeerId; - - if (!targetPeerId) - return; - - try { - // Reset assembly state - const assemblyKey = `${messageId}:${attachment.id}`; - - this.chunkBuffers.delete(assemblyKey); - this.chunkCounts.delete(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.touch(); - - // Notify uploader to stop streaming - const fileCancelEvent: FileCancelEvent = { - type: 'file-cancel', - messageId, - fileId: attachment.id - }; - - this.webrtc.sendToPeer(targetPeerId, fileCancelEvent); - } catch { /* best-effort */ } - } - - /** - * Handle a `file-cancel` from the requester - record the - * cancellation so the streaming loop breaks early. - */ - handleFileCancel(payload: FileCancelPayload): void { - const { messageId, fileId, fromPeerId } = payload; - - if (!messageId || !fileId || !fromPeerId) - return; - - this.cancelledTransfers.add( - this.buildTransferKey(messageId, fileId, fromPeerId) - ); - } - - /** - * Provide a `File` for a pending request (uploader side) and - * stream it to the requesting peer. - */ - async fulfillRequestWithFile( - messageId: string, - fileId: string, - targetPeerId: string, - file: File - ): Promise { - this.originalFiles.set(`${messageId}:${fileId}`, file); - await this.streamFileToPeer(targetPeerId, messageId, fileId, file); - } - - /** Bump the reactive update counter so signal-based consumers re-render. */ - private touch(): void { - this.updated.set(this.updated() + 1); - } - - /** Composite key for transfer-cancellation tracking. */ - private buildTransferKey(messageId: string, fileId: string, peerId: string): string { - return `${messageId}:${fileId}:${peerId}`; - } - - /** Composite key for pending-request tracking. */ - private buildRequestKey(messageId: string, fileId: string): string { - return `${messageId}:${fileId}`; - } - - private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise { - if (!messageId) - return; - - const roomId = await this.resolveMessageRoomId(messageId); - - if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) { - return; - } - - const attachments = this.attachmentsByMessage.get(messageId) ?? []; - - for (const attachment of attachments) { - if (attachmentId && attachment.id !== attachmentId) - continue; - - if (!this.shouldAutoRequestWhenWatched(attachment)) - continue; - - if (attachment.available) - continue; - - if ((attachment.receivedBytes ?? 0) > 0) - continue; - - if (this.pendingRequests.has(this.buildRequestKey(messageId, attachment.id))) - continue; - - this.requestFromAnyPeer(messageId, attachment); - } - } - - private 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); - } - } - } - - private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise> { - const retainedSavedPaths = new Set(); - - for (const [existingMessageId, attachments] of this.attachmentsByMessage) { - 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 async deleteSavedFile(filePath: string): Promise { - const electronApi = this.getElectronApi(); - - if (!electronApi?.deleteFile) - return; - - await electronApi.deleteFile(filePath); - } - - /** Clear any user-facing request error stored on an attachment. */ - private clearAttachmentRequestError(attachment: Attachment): boolean { - if (!attachment.requestError) - return false; - - attachment.requestError = undefined; - return true; - } - - /** Check whether a specific transfer has been cancelled. */ - private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean { - return this.cancelledTransfers.has( - this.buildTransferKey(messageId, fileId, targetPeerId) - ); - } - - /** Check whether a file is inline-previewable media. */ - private isMedia(attachment: { mime: string }): boolean { - return attachment.mime.startsWith('image/') || - attachment.mime.startsWith('video/') || - attachment.mime.startsWith('audio/'); - } - - /** Auto-download only the assets that already supported eager loading when watched. */ - private shouldAutoRequestWhenWatched(attachment: Attachment): boolean { - return attachment.isImage || - (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES); - } - - /** Check whether a completed download should be cached on disk. */ - private shouldPersistDownloadedAttachment(attachment: Attachment): boolean { - return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES || - attachment.mime.startsWith('video/') || - attachment.mime.startsWith('audio/'); - } - - /** - * Send a `file-request` to the best untried peer. - * @returns `true` if a request was dispatched. - */ - private sendFileRequestToNextPeer( - messageId: string, - fileId: string, - preferredPeerId?: string - ): boolean { - const connectedPeers = this.webrtc.getConnectedPeers(); - const requestKey = this.buildRequestKey(messageId, fileId); - const triedPeers = this.pendingRequests.get(requestKey) ?? new Set(); - - // Pick the best untried peer: preferred first, then any - 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.pendingRequests.delete(requestKey); - return false; - } - - triedPeers.add(targetPeerId); - this.pendingRequests.set(requestKey, triedPeers); - - const fileRequestEvent: FileRequestEvent = { - type: 'file-request', - messageId, - fileId - }; - - this.webrtc.sendToPeer(targetPeerId, fileRequestEvent); - - return true; - } - - /** Broadcast a file in base-64 chunks to all connected peers. */ - private async streamFileToPeers( - messageId: string, - fileId: string, - file: File - ): Promise { - const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); - - let offset = 0; - let chunkIndex = 0; - - while (offset < file.size) { - 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 - }; - - this.webrtc.broadcastMessage(fileChunkEvent); - - offset += FILE_CHUNK_SIZE_BYTES; - chunkIndex++; - } - } - - /** Stream a file in base-64 chunks to a single peer. */ - private async streamFileToPeer( - targetPeerId: string, - messageId: string, - fileId: string, - file: File - ): Promise { - const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); - - let offset = 0; - let chunkIndex = 0; - - while (offset < file.size) { - if (this.isTransferCancelled(targetPeerId, messageId, fileId)) - 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++; - } - } - - /** - * Read a file from Electron disk and stream it to a peer as - * base-64 chunks. - */ - private async streamFileFromDiskToPeer( - targetPeerId: string, - messageId: string, - fileId: string, - diskPath: string - ): Promise { - const electronApi = this.getElectronApi(); - - if (!electronApi?.readFile) - return; - - const base64Full = await electronApi.readFile(diskPath); - const fileBytes = this.base64ToUint8Array(base64Full); - const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); - - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { - if (this.isTransferCancelled(targetPeerId, messageId, fileId)) - 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); - } - } - - /** - * Save a file to the Electron app-data directory, organised by - * room name and media type. - */ - private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise { - try { - const electronApi = this.getElectronApi(); - const appDataPath: string | undefined = await electronApi?.getAppDataPath?.(); - - if (!appDataPath || !electronApi?.ensureDir || !electronApi.writeFile) - return; - - const roomName = await this.resolveCurrentRoomName(); - const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; - const subDirectory = attachment.mime.startsWith('video/') - ? 'video' - : attachment.mime.startsWith('audio/') - ? 'audio' - : attachment.mime.startsWith('image/') - ? 'image' - : 'files'; - const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`; - - await electronApi.ensureDir(directoryPath); - - const arrayBuffer = await blob.arrayBuffer(); - const base64 = this.arrayBufferToBase64(arrayBuffer); - const diskPath = `${directoryPath}/${attachment.filename}`; - - await electronApi.writeFile(diskPath, base64); - - attachment.savedPath = diskPath; - void this.persistAttachmentMeta(attachment); - } catch { /* disk save is best-effort */ } - } - - /** On startup, try loading previously saved files from disk (Electron). */ - private async tryLoadSavedFiles(): Promise { - const electronApi = this.getElectronApi(); - - if (!electronApi?.fileExists || !electronApi?.readFile) - return; - - try { - let hasChanges = false; - - for (const [, attachments] of this.attachmentsByMessage) { - for (const attachment of attachments) { - if (attachment.available) - continue; - - // 1. Try savedPath (disk cache) - if (attachment.savedPath) { - try { - if (await electronApi.fileExists(attachment.savedPath)) { - this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.savedPath)); - hasChanges = true; - continue; - } - } catch { /* fall through */ } - } - - // 2. Try filePath (uploader's original) - if (attachment.filePath) { - try { - if (await electronApi.fileExists(attachment.filePath)) { - this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.filePath)); - hasChanges = true; - - if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { - const response = await fetch(attachment.objectUrl!); - - void this.saveFileToDisk(attachment, await response.blob()); - } - - continue; - } - } catch { /* fall through */ } - } - } - } - - if (hasChanges) - this.touch(); - } catch { /* startup load is best-effort */ } - } - - /** - * Helper: decode a base-64 string from disk, create blob + object URL, - * and populate the `originalFiles` map for serving file requests. - */ - 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; - const file = new File([blob], attachment.filename, { type: attachment.mime }); - - this.originalFiles.set(`${attachment.messageId}:${attachment.id}`, file); - } - - /** Save attachment metadata to the database (without file content). */ - private async persistAttachmentMeta(attachment: Attachment): Promise { - 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 */ } - } - - /** Load all attachment metadata from the database. */ - private async loadFromDatabase(): Promise { - try { - const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); - const grouped = new Map(); - - 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.attachmentsByMessage = grouped; - this.touch(); - } catch { /* load is best-effort */ } - } - - 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; - } - - private async resolveMessageRoomId(messageId: string): Promise { - const cachedRoomId = this.messageRoomIds.get(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.rememberMessageRoom(messageId, message.roomId); - return message.roomId; - } catch { - return null; - } - } - - /** One-time migration from localStorage to the database. */ - private async migrateFromLocalStorage(): Promise { - try { - const raw = localStorage.getItem(LEGACY_STORAGE_KEY); - - if (!raw) - return; - - const legacyRecords: AttachmentMeta[] = JSON.parse(raw); - - for (const meta of legacyRecords) { - const existing = this.attachmentsByMessage.get(meta.messageId) ?? []; - - if (!existing.find((entry) => entry.id === meta.id)) { - const attachment: Attachment = { ...meta, - available: false }; - - existing.push(attachment); - this.attachmentsByMessage.set(meta.messageId, existing); - void this.persistAttachmentMeta(attachment); - } - } - - localStorage.removeItem(LEGACY_STORAGE_KEY); - this.touch(); - } catch { /* migration is best-effort */ } - } - - /** Full initialisation sequence: load DB → migrate → restore files. */ - private async initFromDatabase(): Promise { - await this.loadFromDatabase(); - await this.migrateFromLocalStorage(); - await this.tryLoadSavedFiles(); - } - - /** Resolve the display name of the current room via the NgRx store. */ - private resolveCurrentRoomName(): Promise { - return new Promise((resolve) => { - this.ngrxStore - .select(selectCurrentRoomName) - .pipe(take(1)) - .subscribe((name) => resolve(name || '')); - }); - } - - /** Convert an ArrayBuffer to a base-64 string. */ - 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); - } - - /** Convert a base-64 string to a Uint8Array. */ - 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; - } -} diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts deleted file mode 100644 index 4a2260c..0000000 --- a/src/app/core/services/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -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'; diff --git a/src/app/core/services/platform.service.ts b/src/app/core/services/platform.service.ts deleted file mode 100644 index e73b78f..0000000 --- a/src/app/core/services/platform.service.ts +++ /dev/null @@ -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; - } -} diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts deleted file mode 100644 index 2765876..0000000 --- a/src/app/core/services/server-directory.service.ts +++ /dev/null @@ -1,1284 +0,0 @@ -/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */ -import { - Injectable, - signal, - computed -} from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { - Observable, - of, - throwError, - forkJoin -} from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { STORAGE_KEY_CONNECTION_SETTINGS } from '../constants'; -import { ServerInfo, User } from '../models/index'; -import { v4 as uuidv4 } from 'uuid'; -import { environment } from '../../../environments/environment'; - -interface DefaultServerDefinition { - key: string; - name: string; - url: string; -} - -interface HealthCheckPayload { - serverVersion?: unknown; -} - -interface DesktopUpdateStateSnapshot { - currentVersion?: unknown; -} - -interface DesktopUpdateBridge { - getAutoUpdateState?: () => Promise; -} - -type VersionAwareWindow = Window & { - electronAPI?: DesktopUpdateBridge; -}; - -type DefaultEndpointTemplate = Omit & { - defaultKey: string; -}; - -/** - * A configured server endpoint that the user can connect to. - */ -export interface ServerEndpoint { - /** Unique endpoint identifier. */ - id: string; - /** Human-readable label shown in the UI. */ - name: string; - /** Base URL (e.g. `http://localhost:3001`). */ - url: string; - /** Whether this is the currently selected endpoint. */ - isActive: boolean; - /** Whether this is the built-in default endpoint. */ - isDefault: boolean; - /** Stable identifier for a built-in default endpoint. */ - defaultKey?: string; - /** Most recent health-check result. */ - status: 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible'; - /** Last measured round-trip latency (ms). */ - latency?: number; - /** Last reported signaling-server version from /api/health. */ - serverVersion?: string; - /** Local desktop client version used for compatibility checks. */ - clientVersion?: 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; -} - -/** localStorage key that persists the user's configured endpoints. */ -const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; -/** localStorage key that tracks which built-in endpoints the user removed. */ -const REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys'; -/** Timeout (ms) for server health-check and alternative-endpoint pings. */ -const HEALTH_CHECK_TIMEOUT_MS = 5000; - -export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users'; - -function getDefaultHttpProtocol(): 'http' | 'https' { - return typeof window !== 'undefined' && window.location?.protocol === 'https:' - ? 'https' - : 'http'; -} - -function normaliseDefaultServerUrl(rawUrl: string): 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 = `${getDefaultHttpProtocol()}:${cleaned}`; - } else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) { - cleaned = `${getDefaultHttpProtocol()}://${cleaned}`; - } - - cleaned = cleaned.replace(/\/+$/, ''); - - if (cleaned.toLowerCase().endsWith('/api')) { - cleaned = cleaned.slice(0, -4); - } - - return cleaned; -} - -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}`; -} - -/** - * Derive the default server URL from the environment when provided, - * otherwise match the current page protocol automatically. - */ -function buildFallbackDefaultServerUrl(): string { - const configuredUrl = environment.defaultServerUrl?.trim(); - - if (configuredUrl) { - return normaliseDefaultServerUrl(configuredUrl); - } - - return `${getDefaultHttpProtocol()}://localhost:3001`; -} - -function buildDefaultServerDefinitions(): DefaultServerDefinition[] { - const configuredDefaults = Array.isArray(environment.defaultServers) - ? environment.defaultServers - : []; - const seenKeys = new Set(); - const seenUrls = new Set(); - const definitions = configuredDefaults - .map((server, index) => { - const key = server.key?.trim() || `default-${index + 1}`; - const url = normaliseDefaultServerUrl(server.url ?? ''); - - 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() - } - ]; -} - -const DEFAULT_SERVER_DEFINITIONS = buildDefaultServerDefinitions(); -/** Blueprints for built-in default endpoints. */ -const DEFAULT_ENDPOINTS: DefaultEndpointTemplate[] = DEFAULT_SERVER_DEFINITIONS.map( - (definition) => ({ - name: definition.name, - url: definition.url, - isActive: true, - isDefault: true, - defaultKey: definition.key, - status: 'unknown' - }) -); - -function getPrimaryDefaultServerUrl(): string { - return DEFAULT_ENDPOINTS[0]?.url ?? buildFallbackDefaultServerUrl(); -} - -/** - * Manages the user's list of configured server endpoints and - * provides an HTTP client for server-directory API calls - * (search, register, join/leave, heartbeat, etc.). - * - * Endpoints are persisted in `localStorage` and exposed as - * Angular signals for reactive consumption. - */ -@Injectable({ providedIn: 'root' }) -export class ServerDirectoryService { - private readonly _servers = signal([]); - private clientVersionPromise: Promise | null = null; - - /** Whether search queries should be fanned out to all non-offline endpoints. */ - private shouldSearchAllServers = true; - - /** Reactive list of all configured endpoints. */ - readonly servers = computed(() => this._servers()); - - /** Endpoints currently enabled for discovery. */ - readonly activeServers = computed(() => - this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible') - ); - - /** Whether any built-in endpoints are currently missing from the list. */ - readonly hasMissingDefaultServers = computed(() => - DEFAULT_ENDPOINTS.some((endpoint) => !this.hasEndpointForDefault(this._servers(), endpoint)) - ); - - /** The primary active endpoint, falling back to the first configured endpoint. */ - readonly activeServer = computed(() => this.activeServers()[0] ?? null); - - constructor(private readonly http: HttpClient) { - this.loadConnectionSettings(); - this.loadEndpoints(); - void this.testAllServers(); - } - - /** - * Add a new server endpoint (active by default). - * - * @param server - Name and URL of the endpoint to add. - */ - addServer(server: { name: string; url: string }): ServerEndpoint { - const sanitisedUrl = this.sanitiseUrl(server.url); - const newEndpoint: ServerEndpoint = { - id: uuidv4(), - name: server.name, - url: sanitisedUrl, - isActive: true, - isDefault: false, - status: 'unknown' - }; - - this._servers.update((endpoints) => [...endpoints, newEndpoint]); - this.saveEndpoints(); - return newEndpoint; - } - - /** Ensure an endpoint exists for a given URL, optionally activating it. */ - ensureServerEndpoint( - server: { name: string; url: string }, - options?: { setActive?: boolean } - ): ServerEndpoint { - const sanitisedUrl = this.sanitiseUrl(server.url); - const existing = this.findServerByUrl(sanitisedUrl); - - if (existing) { - if (options?.setActive) { - this.setActiveServer(existing.id); - } - - return existing; - } - - const created = this.addServer({ name: server.name, - url: sanitisedUrl }); - - if (options?.setActive) { - this.setActiveServer(created.id); - } - - return created; - } - - /** Find a configured endpoint by URL. */ - findServerByUrl(url: string): ServerEndpoint | undefined { - const sanitisedUrl = this.sanitiseUrl(url); - - return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl); - } - - /** - * Remove an endpoint by ID. - * When the removed endpoint was active, the first remaining endpoint - * becomes active. - */ - removeServer(endpointId: string): void { - const endpoints = this._servers(); - const target = endpoints.find((endpoint) => endpoint.id === endpointId); - - if (!target || endpoints.length <= 1) - return; - - const wasActive = target.isActive; - - if (target.isDefault) { - this.markDefaultEndpointRemoved(target); - } - - this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId)); - - if (wasActive) { - this._servers.update((list) => { - if (list.length > 0 && !list.some((endpoint) => endpoint.isActive)) { - list[0] = { ...list[0], - isActive: true }; - } - - return [...list]; - }); - } - - this.saveEndpoints(); - } - - /** Restore any missing built-in endpoints without touching existing ones. */ - restoreDefaultServers(): ServerEndpoint[] { - const currentEndpoints = this._servers(); - const restoredEndpoints: ServerEndpoint[] = []; - - for (const defaultEndpoint of DEFAULT_ENDPOINTS) { - if (this.hasEndpointForDefault(currentEndpoints, defaultEndpoint)) { - continue; - } - - restoredEndpoints.push({ - ...defaultEndpoint, - id: uuidv4(), - isActive: true - }); - } - - if (restoredEndpoints.length === 0) { - this.clearRemovedDefaultEndpointKeys(); - return []; - } - - this._servers.update((endpoints) => { - const next = [...endpoints, ...restoredEndpoints]; - - if (!next.some((endpoint) => endpoint.isActive)) { - next[0] = { ...next[0], - isActive: true }; - } - - return next; - }); - - this.clearRemovedDefaultEndpointKeys(); - this.saveEndpoints(); - return restoredEndpoints; - } - - /** Mark an endpoint as active without changing other active endpoints. */ - 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(); - } - - /** Deactivate an endpoint while keeping at least one endpoint active. */ - deactivateServer(endpointId: string): void { - const activeEndpointCount = this.activeServers().length; - - if (activeEndpointCount <= 1) { - return; - } - - this._servers.update((endpoints) => - endpoints.map((endpoint) => - endpoint.id === endpointId ? { ...endpoint, - isActive: false } : endpoint - ) - ); - - this.saveEndpoints(); - } - - /** Update the health status and optional latency of an endpoint. */ - updateServerStatus( - endpointId: string, - status: ServerEndpoint['status'], - latency?: number, - versions?: { - serverVersion?: string | null; - clientVersion?: string | null; - } - ): void { - this._servers.update((endpoints) => { - const updatedEndpoints = 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 - }; - }); - - if (updatedEndpoints.some((endpoint) => endpoint.isActive)) { - return updatedEndpoints; - } - - const fallbackIndex = updatedEndpoints.findIndex((endpoint) => endpoint.status !== 'incompatible'); - - if (fallbackIndex < 0) { - return updatedEndpoints; - } - - const nextEndpoints = [...updatedEndpoints]; - - nextEndpoints[fallbackIndex] = { - ...nextEndpoints[fallbackIndex], - isActive: true - }; - - return nextEndpoints; - }); - - this.saveEndpoints(); - } - - /** Verify whether a selector resolves to an endpoint compatible with this client version. */ - async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise { - const endpoint = this.resolveEndpoint(selector); - - if (!endpoint) { - return false; - } - - if (endpoint.status === 'incompatible') { - return false; - } - - const clientVersion = await this.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'; - } - - /** Enable or disable fan-out search across all endpoints. */ - setSearchAllServers(enabled: boolean): void { - this.shouldSearchAllServers = enabled; - } - - /** - * Probe a single endpoint's health and update its status. - * - * @param endpointId - ID of the endpoint to test. - * @returns `true` if the server responded successfully. - */ - async testServer(endpointId: string): Promise { - const endpoint = this._servers().find((entry) => entry.id === endpointId); - - if (!endpoint) - return false; - - this.updateServerStatus(endpointId, 'checking'); - const startTime = Date.now(); - const clientVersion = await this.getClientVersion(); - - try { - const response = await fetch(`${endpoint.url}/api/health`, { - method: 'GET', - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) - }); - const latency = Date.now() - startTime; - - if (response.ok) { - const payload = await response.json() as HealthCheckPayload; - const serverVersion = normalizeSemanticVersion(payload.serverVersion); - const isVersionCompatible = !clientVersion - || (serverVersion !== null && serverVersion === clientVersion); - - if (!isVersionCompatible) { - this.updateServerStatus(endpointId, 'incompatible', latency, { - serverVersion, - clientVersion - }); - - return false; - } - - this.updateServerStatus(endpointId, 'online', latency, { - serverVersion, - clientVersion - }); - - return true; - } - - this.updateServerStatus(endpointId, 'offline'); - return false; - } catch { - // Fall back to the /servers endpoint - try { - const response = await fetch(`${endpoint.url}/api/servers`, { - method: 'GET', - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) - }); - const latency = Date.now() - startTime; - - if (response.ok) { - this.updateServerStatus(endpointId, 'online', latency); - return true; - } - } catch { /* both checks failed */ } - - this.updateServerStatus(endpointId, 'offline'); - return false; - } - } - - /** Probe all configured endpoints in parallel. */ - async testAllServers(): Promise { - const endpoints = this._servers(); - - await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id))); - } - - /** Expose the API base URL for external consumers. */ - getApiBaseUrl(selector?: ServerSourceSelector): string { - return this.buildApiBaseUrl(selector); - } - - /** Get the WebSocket URL derived from the active endpoint. */ - getWebSocketUrl(selector?: ServerSourceSelector): string { - return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws'); - } - - /** - * Search for public servers matching a query string. - * When {@link shouldSearchAllServers} is `true`, the search is - * fanned out to every non-offline endpoint. - */ - searchServers(query: string): Observable { - if (this.shouldSearchAllServers) { - return this.searchAllEndpoints(query); - } - - return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); - } - - /** Retrieve the full list of public servers. */ - getServers(): Observable { - if (this.shouldSearchAllServers) { - return this.getAllServersFromAllEndpoints(); - } - - return this.http - .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) - .pipe( - map((response) => this.normalizeServerList(response, this.activeServer())), - catchError((error) => { - console.error('Failed to get servers:', error); - return of([]); - }) - ); - } - - /** Fetch details for a single server. */ - getServer(serverId: string, selector?: ServerSourceSelector): Observable { - return this.http - .get(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`) - .pipe( - map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), - catchError((error) => { - console.error('Failed to get server:', error); - return of(null); - }) - ); - } - - /** Register a new server listing in the directory. */ - registerServer( - server: Omit & { id?: string; password?: string | null }, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers`, server) - .pipe( - catchError((error) => { - console.error('Failed to register server:', error); - return throwError(() => error); - }) - ); - } - - /** Update an existing server listing. */ - updateServer( - serverId: string, - updates: Partial & { - currentOwnerId: string; - actingRole?: string; - password?: string | null; - }, - selector?: ServerSourceSelector - ): Observable { - return this.http - .put(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`, updates) - .pipe( - catchError((error) => { - console.error('Failed to update server:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a server listing from the directory. */ - unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable { - return this.http - .delete(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`) - .pipe( - catchError((error) => { - console.error('Failed to unregister server:', error); - return throwError(() => error); - }) - ); - } - - /** Retrieve users currently connected to a server. */ - getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable { - return this.http - .get(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/users`) - .pipe( - catchError((error) => { - console.error('Failed to get server users:', error); - return of([]); - }) - ); - } - - /** Send a join request for a server and receive the signaling URL. */ - requestJoin( - request: ServerJoinAccessRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post( - `${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`, - request - ) - .pipe( - catchError((error) => { - console.error('Failed to send join request:', error); - return throwError(() => error); - }) - ); - } - - /** Create an expiring invite link for a server. */ - createInvite( - serverId: string, - request: CreateServerInviteRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/invites`, request) - .pipe( - catchError((error) => { - console.error('Failed to create invite:', error); - return throwError(() => error); - }) - ); - } - - /** Retrieve public invite metadata. */ - getInvite(inviteId: string, selector?: ServerSourceSelector): Observable { - return this.http - .get(`${this.buildApiBaseUrl(selector)}/invites/${inviteId}`) - .pipe( - catchError((error) => { - console.error('Failed to get invite:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a member's stored join access for a server. */ - kickServerMember( - serverId: string, - request: KickServerMemberRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request) - .pipe( - catchError((error) => { - console.error('Failed to kick server member:', error); - return throwError(() => error); - }) - ); - } - - /** Ban a member from a server invite/password access list. */ - banServerMember( - serverId: string, - request: BanServerMemberRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request) - .pipe( - catchError((error) => { - console.error('Failed to ban server member:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a stored server ban. */ - unbanServerMember( - serverId: string, - request: UnbanServerMemberRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request) - .pipe( - catchError((error) => { - console.error('Failed to unban server member:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a user's remembered membership after leaving a server. */ - notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }) - .pipe( - catchError((error) => { - console.error('Failed to notify leave:', error); - return of(undefined); - }) - ); - } - - /** Update the live user count for a server listing. */ - updateUserCount(serverId: string, count: number): Observable { - return this.http - .patch(`${this.buildApiBaseUrl()}/servers/${serverId}/user-count`, { count }) - .pipe( - catchError((error) => { - console.error('Failed to update user count:', error); - return of(undefined); - }) - ); - } - - /** Send a heartbeat to keep the server listing active. */ - sendHeartbeat(serverId: string): Observable { - return this.http - .post(`${this.buildApiBaseUrl()}/servers/${serverId}/heartbeat`, {}) - .pipe( - catchError((error) => { - console.error('Failed to send heartbeat:', error); - return of(undefined); - }) - ); - } - - /** - * Build the active endpoint's API base URL, stripping trailing - * slashes and accidental `/api` suffixes. - */ - private buildApiBaseUrl(selector?: ServerSourceSelector): string { - return `${this.resolveBaseServerUrl(selector)}/api`; - } - - /** Strip trailing slashes and `/api` suffix from a URL. */ - private sanitiseUrl(rawUrl: string): string { - let cleaned = rawUrl.trim().replace(/\/+$/, ''); - - if (cleaned.toLowerCase().endsWith('/api')) { - cleaned = cleaned.slice(0, -4); - } - - return cleaned; - } - - private resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null { - if (selector?.sourceId) { - return this._servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null; - } - - if (selector?.sourceUrl) { - return this.findServerByUrl(selector.sourceUrl) ?? null; - } - - return this.activeServer() - ?? this._servers().find((endpoint) => endpoint.status !== 'incompatible') - ?? this._servers()[0] - ?? null; - } - - private resolveBaseServerUrl(selector?: ServerSourceSelector): string { - if (selector?.sourceUrl) { - return this.sanitiseUrl(selector.sourceUrl); - } - - return this.resolveEndpoint(selector)?.url ?? getPrimaryDefaultServerUrl(); - } - - /** - * Handle both `{ servers: [...] }` and direct `ServerInfo[]` - * response shapes from the directory API. - */ - private unwrapServersResponse( - response: { servers: ServerInfo[]; total: number } | ServerInfo[] - ): ServerInfo[] { - if (Array.isArray(response)) - return response; - - return response.servers ?? []; - } - - /** Search a single endpoint for servers matching a query. */ - private searchSingleEndpoint( - query: string, - apiBaseUrl: string, - source?: ServerEndpoint | null - ): Observable { - 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([]); - }) - ); - } - - /** Fan-out search across all non-offline endpoints, deduplicating results. */ - private searchAllEndpoints(query: string): Observable { - const onlineEndpoints = this.activeServers().filter( - (endpoint) => endpoint.status !== 'offline' - ); - - if (onlineEndpoints.length === 0) { - return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); - } - - const requests = onlineEndpoints.map((endpoint) => - this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint) - ); - - return forkJoin(requests).pipe( - map((resultArrays) => resultArrays.flat()), - map((servers) => this.deduplicateById(servers)) - ); - } - - /** Retrieve all servers from all non-offline endpoints. */ - private getAllServersFromAllEndpoints(): Observable { - const onlineEndpoints = this.activeServers().filter( - (endpoint) => endpoint.status !== 'offline' - ); - - if (onlineEndpoints.length === 0) { - return this.http - .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) - .pipe( - map((response) => this.normalizeServerList(response, this.activeServer())), - catchError(() => of([])) - ); - } - - const requests = 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[])) - ) - ); - - return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat())); - } - - /** Remove duplicate servers (by `id`), keeping the first occurrence. */ - private deduplicateById(items: T[]): T[] { - const seen = new Set(); - - 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, - source?: ServerEndpoint | null - ): ServerInfo { - const candidate = server as Record; - 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.sanitiseUrl(sourceUrl) - : (source ? this.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; - } - - private async getClientVersion(): Promise { - if (!this.clientVersionPromise) { - this.clientVersionPromise = this.resolveClientVersion(); - } - - return this.clientVersionPromise; - } - - private async resolveClientVersion(): Promise { - if (typeof window === 'undefined') { - return null; - } - - const electronApi = (window as VersionAwareWindow).electronAPI; - - if (!electronApi?.getAutoUpdateState) { - return null; - } - - try { - const state = await electronApi.getAutoUpdateState(); - - return normalizeSemanticVersion(state?.currentVersion); - } catch { - return null; - } - } - - /** Apply persisted connection settings before any directory queries run. */ - 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; - } - } - - /** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */ - private loadEndpoints(): void { - const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); - - if (!stored) { - this.initialiseDefaultEndpoints(); - return; - } - - try { - const parsed = JSON.parse(stored) as ServerEndpoint[]; - const endpoints = this.reconcileStoredEndpoints(parsed); - - this._servers.set(endpoints); - this.saveEndpoints(); - } catch { - this.initialiseDefaultEndpoints(); - } - } - - /** Create and persist the built-in default endpoints. */ - private initialiseDefaultEndpoints(): void { - const defaultEndpoints = DEFAULT_ENDPOINTS.map((endpoint) => ({ - ...endpoint, - id: uuidv4() - })); - - this._servers.set(defaultEndpoints); - this.saveEndpoints(); - } - - private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] { - const reconciled: ServerEndpoint[] = []; - const claimedDefaultKeys = new Set(); - const removedDefaultKeys = this.loadRemovedDefaultEndpointKeys(); - - for (const endpoint of Array.isArray(storedEndpoints) ? storedEndpoints : []) { - if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') { - continue; - } - - const sanitisedUrl = this.sanitiseUrl(endpoint.url); - const matchedDefault = this.matchDefaultEndpoint(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 DEFAULT_ENDPOINTS) { - if ( - !claimedDefaultKeys.has(defaultEndpoint.defaultKey) - && !removedDefaultKeys.has(defaultEndpoint.defaultKey) - && !this.hasEndpointForDefault(reconciled, defaultEndpoint) - ) { - reconciled.push({ - ...defaultEndpoint, - id: uuidv4(), - isActive: defaultEndpoint.isActive - }); - } - } - - if (reconciled.length > 0 && !reconciled.some((endpoint) => endpoint.isActive)) { - reconciled[0] = { ...reconciled[0], - isActive: true }; - } - - return reconciled; - } - - private matchDefaultEndpoint( - endpoint: ServerEndpoint, - sanitisedUrl: string, - claimedDefaultKeys: Set - ): DefaultEndpointTemplate | null { - if (endpoint.defaultKey) { - return DEFAULT_ENDPOINTS.find( - (candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey) - ) ?? null; - } - - if (!endpoint.isDefault) { - return null; - } - - const matchingCurrentDefault = DEFAULT_ENDPOINTS.find( - (candidate) => candidate.url === sanitisedUrl && candidate.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey) - ); - - if (matchingCurrentDefault) { - return matchingCurrentDefault; - } - - return DEFAULT_ENDPOINTS.find( - (candidate) => candidate.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey) - ) ?? null; - } - - private hasEndpointForDefault( - endpoints: ServerEndpoint[], - defaultEndpoint: DefaultEndpointTemplate - ): boolean { - return endpoints.some((endpoint) => - endpoint.defaultKey === defaultEndpoint.defaultKey - || this.sanitiseUrl(endpoint.url) === defaultEndpoint.url - ); - } - - private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void { - const defaultKey = endpoint.defaultKey ?? this.findDefaultEndpointKeyByUrl(endpoint.url); - - if (!defaultKey) { - return; - } - - const removedDefaultKeys = this.loadRemovedDefaultEndpointKeys(); - - removedDefaultKeys.add(defaultKey); - this.saveRemovedDefaultEndpointKeys(removedDefaultKeys); - } - - private findDefaultEndpointKeyByUrl(url: string): string | null { - const sanitisedUrl = this.sanitiseUrl(url); - - return DEFAULT_ENDPOINTS.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null; - } - - private loadRemovedDefaultEndpointKeys(): Set { - const stored = localStorage.getItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY); - - if (!stored) { - return new Set(); - } - - try { - const parsed = JSON.parse(stored) as unknown; - - if (!Array.isArray(parsed)) { - return new Set(); - } - - return new Set(parsed.filter((value): value is string => typeof value === 'string')); - } catch { - return new Set(); - } - } - - private saveRemovedDefaultEndpointKeys(keys: Set): void { - if (keys.size === 0) { - localStorage.removeItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY); - return; - } - - localStorage.setItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY, JSON.stringify([...keys])); - } - - private clearRemovedDefaultEndpointKeys(): void { - localStorage.removeItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY); - } - - /** Persist the current endpoint list to localStorage. */ - private saveEndpoints(): void { - localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers())); - } -} diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts deleted file mode 100644 index 00cf396..0000000 --- a/src/app/core/services/webrtc.service.ts +++ /dev/null @@ -1,1388 +0,0 @@ -/** - * WebRTCService - thin Angular service that composes specialised managers. - * - * Each concern lives in its own file under `./webrtc/`: - * • SignalingManager - WebSocket lifecycle & reconnection - * • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels - * • MediaManager - mic voice, mute, deafen, bitrate - * • ScreenShareManager - screen capture & mixed audio - * • WebRTCLogger - debug / diagnostic logging - * - * This file wires them together and exposes a public API that is - * identical to the old monolithic service so consumers don't change. - */ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars */ -import { - Injectable, - signal, - computed, - inject, - OnDestroy -} from '@angular/core'; -import { - Observable, - of, - Subject, - Subscription -} from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; -import { SignalingMessage, ChatEvent } from '../models/index'; -import { TimeSyncService } from './time-sync.service'; -import { DebuggingService } from './debugging.service'; -import { ScreenShareSourcePickerService } from './screen-share-source-picker.service'; - -import { - SignalingManager, - PeerConnectionManager, - MediaManager, - ScreenShareManager, - WebRTCLogger, - IdentifyCredentials, - JoinedServerInfo, - VoiceStateSnapshot, - LatencyProfile, - ScreenShareStartOptions, - SIGNALING_TYPE_IDENTIFY, - SIGNALING_TYPE_JOIN_SERVER, - SIGNALING_TYPE_VIEW_SERVER, - SIGNALING_TYPE_LEAVE_SERVER, - SIGNALING_TYPE_OFFER, - SIGNALING_TYPE_ANSWER, - SIGNALING_TYPE_ICE_CANDIDATE, - SIGNALING_TYPE_CONNECTED, - SIGNALING_TYPE_SERVER_USERS, - SIGNALING_TYPE_USER_JOINED, - SIGNALING_TYPE_USER_LEFT, - DEFAULT_DISPLAY_NAME, - P2P_TYPE_SCREEN_SHARE_REQUEST, - P2P_TYPE_SCREEN_SHARE_STOP, - P2P_TYPE_VOICE_STATE, - P2P_TYPE_SCREEN_STATE -} from './webrtc'; - -interface SignalingUserSummary { - oderId: string; - displayName: string; -} - -interface IncomingSignalingPayload { - sdp?: RTCSessionDescriptionInit; - candidate?: RTCIceCandidateInit; -} - -type IncomingSignalingMessage = Omit, 'type' | 'payload'> & { - type: string; - payload?: IncomingSignalingPayload; - oderId?: string; - serverTime?: number; - serverId?: string; - serverIds?: string[]; - users?: SignalingUserSummary[]; - displayName?: string; - fromUserId?: string; -}; - -@Injectable({ - providedIn: 'root' -}) -export class WebRTCService implements OnDestroy { - private readonly timeSync = inject(TimeSyncService); - private readonly debugging = inject(DebuggingService); - private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService); - - private readonly logger = new WebRTCLogger(() => this.debugging.enabled()); - - private lastIdentifyCredentials: IdentifyCredentials | null = null; - private readonly lastJoinedServerBySignalUrl = new Map(); - private readonly memberServerIdsBySignalUrl = new Map>(); - private readonly serverSignalingUrlMap = new Map(); - private readonly peerSignalingUrlMap = new Map(); - private readonly signalingManagers = new Map(); - private readonly signalingSubscriptions = new Map(); - private readonly signalingConnectionStates = new Map(); - private activeServerId: string | null = null; - /** The server ID where voice is currently active, or `null` when not in voice. */ - private voiceServerId: string | null = null; - /** Maps each remote peer ID to the shared servers they currently belong to. */ - private readonly peerServerMap = new Map>(); - private readonly serviceDestroyed$ = new Subject(); - private remoteScreenShareRequestsEnabled = false; - private readonly desiredRemoteScreenSharePeers = new Set(); - private readonly activeRemoteScreenSharePeers = new Set(); - - private readonly _localPeerId = signal(uuidv4()); - private readonly _isSignalingConnected = signal(false); - private readonly _isVoiceConnected = signal(false); - private readonly _connectedPeers = signal([]); - private readonly _isMuted = signal(false); - private readonly _isDeafened = signal(false); - private readonly _isScreenSharing = signal(false); - private readonly _isNoiseReductionEnabled = signal(false); - private readonly _screenStreamSignal = signal(null); - private readonly _isScreenShareRemotePlaybackSuppressed = signal(false); - private readonly _forceDefaultRemotePlaybackOutput = signal(false); - private readonly _hasConnectionError = signal(false); - private readonly _connectionErrorMessage = signal(null); - private readonly _hasEverConnected = signal(false); - /** - * Reactive snapshot of per-peer latencies (ms). - * Updated whenever a ping/pong round-trip completes. - * Keyed by remote peer (oderId). - */ - private readonly _peerLatencies = signal>(new Map()); - - // Public computed signals (unchanged external API) - readonly peerId = computed(() => this._localPeerId()); - readonly isConnected = computed(() => this._isSignalingConnected()); - readonly hasEverConnected = computed(() => this._hasEverConnected()); - readonly isVoiceConnected = computed(() => this._isVoiceConnected()); - readonly connectedPeers = computed(() => this._connectedPeers()); - readonly isMuted = computed(() => this._isMuted()); - readonly isDeafened = computed(() => this._isDeafened()); - readonly isScreenSharing = computed(() => this._isScreenSharing()); - readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled()); - readonly screenStream = computed(() => this._screenStreamSignal()); - readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed()); - readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput()); - readonly hasConnectionError = computed(() => this._hasConnectionError()); - readonly connectionErrorMessage = computed(() => this._connectionErrorMessage()); - readonly shouldShowConnectionError = computed(() => { - if (!this._hasConnectionError()) - return false; - - if (this._isVoiceConnected() && this._connectedPeers().length > 0) - return false; - - return true; - }); - /** Per-peer latency map (ms). Read via `peerLatencies()`. */ - readonly peerLatencies = computed(() => this._peerLatencies()); - - private readonly signalingMessage$ = new Subject(); - readonly onSignalingMessage = this.signalingMessage$.asObservable(); - - // Delegates to managers - get onMessageReceived(): Observable { - return this.peerManager.messageReceived$.asObservable(); - } - get onPeerConnected(): Observable { - return this.peerManager.peerConnected$.asObservable(); - } - get onPeerDisconnected(): Observable { - return this.peerManager.peerDisconnected$.asObservable(); - } - get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { - return this.peerManager.remoteStream$.asObservable(); - } - get onVoiceConnected(): Observable { - return this.mediaManager.voiceConnected$.asObservable(); - } - - private readonly peerManager: PeerConnectionManager; - private readonly mediaManager: MediaManager; - private readonly screenShareManager: ScreenShareManager; - - constructor() { - // Create managers with null callbacks first to break circular initialization - this.peerManager = new PeerConnectionManager(this.logger, null!); - - this.mediaManager = new MediaManager(this.logger, null!); - - this.screenShareManager = new ScreenShareManager(this.logger, null!); - - // Now wire up cross-references (all managers are instantiated) - this.peerManager.setCallbacks({ - sendRawMessage: (msg: Record) => this.sendRawMessage(msg), - getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), - isSignalingConnected: (): boolean => this._isSignalingConnected(), - getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(), - getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials, - getLocalPeerId: (): string => this._localPeerId(), - isScreenSharingActive: (): boolean => this._isScreenSharing() - }); - - this.mediaManager.setCallbacks({ - getActivePeers: (): Map => - this.peerManager.activePeerConnections, - renegotiate: (peerId: string): Promise => this.peerManager.renegotiate(peerId), - broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event), - getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(), - getIdentifyDisplayName: (): string => - this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME - }); - - this.screenShareManager.setCallbacks({ - getActivePeers: (): Map => - this.peerManager.activePeerConnections, - getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), - renegotiate: (peerId: string): Promise => this.peerManager.renegotiate(peerId), - broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(), - selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open( - sources, - options.includeSystemAudio - ), - updateLocalScreenShareState: (state): void => { - this._isScreenSharing.set(state.active); - this._screenStreamSignal.set(state.stream); - this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback); - this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput); - } - }); - - this.wireManagerEvents(); - } - - private wireManagerEvents(): void { - // Internal control-plane messages for on-demand screen-share delivery. - this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event)); - - // Peer manager → connected peers signal - this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => - this._connectedPeers.set(peers) - ); - - // If we are already sharing when a new peer connection finishes, push the - // current screen-share tracks to that peer and renegotiate. - this.peerManager.peerConnected$.subscribe((peerId) => { - if (!this.screenShareManager.getIsScreenActive()) { - if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) { - this.requestRemoteScreenShares([peerId]); - } - - return; - } - - this.screenShareManager.syncScreenShareToPeer(peerId); - - if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) { - this.requestRemoteScreenShares([peerId]); - } - }); - - this.peerManager.peerDisconnected$.subscribe((peerId) => { - this.activeRemoteScreenSharePeers.delete(peerId); - this.peerServerMap.delete(peerId); - this.peerSignalingUrlMap.delete(peerId); - this.screenShareManager.clearScreenShareRequest(peerId); - }); - - // Media manager → voice connected signal - this.mediaManager.voiceConnected$.subscribe(() => { - this._isVoiceConnected.set(true); - }); - - // Peer manager → latency updates - this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => { - const next = new Map(this.peerManager.peerLatencies); - - this._peerLatencies.set(next); - }); - } - - private ensureSignalingManager(signalUrl: string): SignalingManager { - const existingManager = this.signalingManagers.get(signalUrl); - - if (existingManager) { - return existingManager; - } - - const manager = new SignalingManager( - this.logger, - () => this.lastIdentifyCredentials, - () => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null, - () => this.getMemberServerIdsForSignalUrl(signalUrl) - ); - const subscriptions: Subscription[] = [ - manager.connectionStatus$.subscribe(({ connected, errorMessage }) => - this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage) - ), - manager.messageReceived$.subscribe((message) => this.handleSignalingMessage(message, signalUrl)), - manager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates()) - ]; - - this.signalingManagers.set(signalUrl, manager); - this.signalingSubscriptions.set(signalUrl, subscriptions); - return manager; - } - - private handleSignalingConnectionStatus( - signalUrl: string, - connected: boolean, - errorMessage?: string - ): void { - this.signalingConnectionStates.set(signalUrl, connected); - - if (connected) - this._hasEverConnected.set(true); - - const anyConnected = this.isAnySignalingConnected(); - - this._isSignalingConnected.set(anyConnected); - this._hasConnectionError.set(!anyConnected); - this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server')); - } - - private isAnySignalingConnected(): boolean { - for (const manager of this.signalingManagers.values()) { - if (manager.isSocketOpen()) { - return true; - } - } - - return false; - } - - private getConnectedSignalingManagers(): { signalUrl: string; manager: SignalingManager }[] { - const connectedManagers: { signalUrl: string; manager: SignalingManager }[] = []; - - for (const [signalUrl, manager] of this.signalingManagers.entries()) { - if (!manager.isSocketOpen()) { - continue; - } - - connectedManagers.push({ signalUrl, - manager }); - } - - return connectedManagers; - } - - private getOrCreateMemberServerSet(signalUrl: string): Set { - const existingSet = this.memberServerIdsBySignalUrl.get(signalUrl); - - if (existingSet) { - return existingSet; - } - - const createdSet = new Set(); - - this.memberServerIdsBySignalUrl.set(signalUrl, createdSet); - return createdSet; - } - - private getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet { - return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set(); - } - - private isJoinedServer(serverId: string): boolean { - for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { - if (memberServerIds.has(serverId)) { - return true; - } - } - - return false; - } - - private getJoinedServerCount(): number { - let joinedServerCount = 0; - - for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { - joinedServerCount += memberServerIds.size; - } - - return joinedServerCount; - } - - private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - this.signalingMessage$.next(message); - this.logger.info('Signaling message', { - signalUrl, - type: message.type - }); - - switch (message.type) { - case SIGNALING_TYPE_CONNECTED: - this.handleConnectedSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_SERVER_USERS: - this.handleServerUsersSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_USER_JOINED: - this.handleUserJoinedSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_USER_LEFT: - this.handleUserLeftSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_OFFER: - this.handleOfferSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_ANSWER: - this.handleAnswerSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_ICE_CANDIDATE: - this.handleIceCandidateSignalingMessage(message, signalUrl); - return; - - default: - return; - } - } - - private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - this.logger.info('Server connected', { - oderId: message.oderId, - signalUrl - }); - - if (message.serverId) { - this.serverSignalingUrlMap.set(message.serverId, signalUrl); - } - - if (typeof message.serverTime === 'number') { - this.timeSync.setFromServerTime(message.serverTime); - } - } - - private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - const users = Array.isArray(message.users) ? message.users : []; - - this.logger.info('Server users', { - count: users.length, - signalUrl, - serverId: message.serverId - }); - - if (message.serverId) { - this.serverSignalingUrlMap.set(message.serverId, signalUrl); - } - - for (const user of users) { - if (!user.oderId) - continue; - - this.peerSignalingUrlMap.set(user.oderId, signalUrl); - - if (message.serverId) { - this.trackPeerInServer(user.oderId, message.serverId); - } - - const existing = this.peerManager.activePeerConnections.get(user.oderId); - - if (this.canReusePeerConnection(existing)) { - this.logger.info('Reusing active peer connection', { - connectionState: existing?.connection.connectionState ?? 'unknown', - dataChannelState: existing?.dataChannel?.readyState ?? 'missing', - oderId: user.oderId, - serverId: message.serverId, - signalUrl - }); - continue; - } - - if (existing) { - this.logger.info('Removing failed peer before recreate', { - connectionState: existing.connection.connectionState, - dataChannelState: existing.dataChannel?.readyState ?? 'missing', - oderId: user.oderId, - serverId: message.serverId, - signalUrl - }); - this.peerManager.removePeer(user.oderId); - } - - this.logger.info('Create peer connection to existing user', { - oderId: user.oderId, - serverId: message.serverId, - signalUrl - }); - - this.peerManager.createPeerConnection(user.oderId, true); - void this.peerManager.createAndSendOffer(user.oderId); - } - } - - private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - this.logger.info('User joined', { - displayName: message.displayName, - oderId: message.oderId, - signalUrl - }); - - if (message.serverId) { - this.serverSignalingUrlMap.set(message.serverId, signalUrl); - } - - if (message.oderId) { - this.peerSignalingUrlMap.set(message.oderId, signalUrl); - } - - if (message.oderId && message.serverId) { - this.trackPeerInServer(message.oderId, message.serverId); - } - } - - private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - this.logger.info('User left', { - displayName: message.displayName, - oderId: message.oderId, - signalUrl, - serverId: message.serverId - }); - - if (message.oderId) { - const hasRemainingSharedServers = Array.isArray(message.serverIds) - ? this.replacePeerSharedServers(message.oderId, message.serverIds) - : (message.serverId - ? this.untrackPeerFromServer(message.oderId, message.serverId) - : false); - - if (!hasRemainingSharedServers) { - this.peerManager.removePeer(message.oderId); - this.peerServerMap.delete(message.oderId); - this.peerSignalingUrlMap.delete(message.oderId); - } - } - } - - private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - const fromUserId = message.fromUserId; - const sdp = message.payload?.sdp; - - if (!fromUserId || !sdp) - return; - - this.peerSignalingUrlMap.set(fromUserId, signalUrl); - - const offerEffectiveServer = this.voiceServerId || this.activeServerId; - - if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) { - this.trackPeerInServer(fromUserId, offerEffectiveServer); - } - - this.peerManager.handleOffer(fromUserId, sdp); - } - - private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - const fromUserId = message.fromUserId; - const sdp = message.payload?.sdp; - - if (!fromUserId || !sdp) - return; - - this.peerSignalingUrlMap.set(fromUserId, signalUrl); - - this.peerManager.handleAnswer(fromUserId, sdp); - } - - private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - const fromUserId = message.fromUserId; - const candidate = message.payload?.candidate; - - if (!fromUserId || !candidate) - return; - - this.peerSignalingUrlMap.set(fromUserId, signalUrl); - - this.peerManager.handleIceCandidate(fromUserId, candidate); - } - - /** - * Close all peer connections that were discovered from a server - * other than `serverId`. Also removes their entries from - * {@link peerServerMap} so the bookkeeping stays clean. - * - * This ensures audio (and data channels) are scoped to only - * the voice-active (or currently viewed) server. - */ - private closePeersNotInServer(serverId: string): void { - const peersToClose: string[] = []; - - this.peerServerMap.forEach((peerServerIds, peerId) => { - if (!peerServerIds.has(serverId)) { - peersToClose.push(peerId); - } - }); - - for (const peerId of peersToClose) { - this.logger.info('Closing peer from different server', { peerId, - currentServer: serverId }); - - this.peerManager.removePeer(peerId); - this.peerServerMap.delete(peerId); - this.peerSignalingUrlMap.delete(peerId); - } - } - - private getCurrentVoiceState(): VoiceStateSnapshot { - return { - isConnected: this._isVoiceConnected(), - isMuted: this._isMuted(), - isDeafened: this._isDeafened(), - isScreenSharing: this._isScreenSharing(), - roomId: this.mediaManager.getCurrentVoiceRoomId(), - serverId: this.mediaManager.getCurrentVoiceServerId() - }; - } - - // PUBLIC API - matches the old monolithic service's interface - - /** - * Connect to a signaling server via WebSocket. - * - * @param serverUrl - The WebSocket URL of the signaling server. - * @returns An observable that emits `true` once connected. - */ - connectToSignalingServer(serverUrl: string): Observable { - const manager = this.ensureSignalingManager(serverUrl); - - if (manager.isSocketOpen()) { - return of(true); - } - - return manager.connect(serverUrl); - } - - /** Returns true when the signaling socket for a given URL is currently open. */ - isSignalingConnectedTo(serverUrl: string): boolean { - return this.signalingManagers.get(serverUrl)?.isSocketOpen() ?? false; - } - - private trackPeerInServer(peerId: string, serverId: string): void { - if (!peerId || !serverId) - return; - - const trackedServers = this.peerServerMap.get(peerId) ?? new Set(); - - trackedServers.add(serverId); - this.peerServerMap.set(peerId, trackedServers); - } - - private replacePeerSharedServers(peerId: string, serverIds: string[]): boolean { - const sharedServerIds = serverIds.filter((serverId) => this.isJoinedServer(serverId)); - - if (sharedServerIds.length === 0) { - this.peerServerMap.delete(peerId); - return false; - } - - this.peerServerMap.set(peerId, new Set(sharedServerIds)); - return true; - } - - private untrackPeerFromServer(peerId: string, serverId: string): boolean { - const trackedServers = this.peerServerMap.get(peerId); - - if (!trackedServers) - return false; - - trackedServers.delete(serverId); - - if (trackedServers.size === 0) { - this.peerServerMap.delete(peerId); - return false; - } - - this.peerServerMap.set(peerId, trackedServers); - return true; - } - - /** - * Ensure the signaling WebSocket is connected, reconnecting if needed. - * - * @param timeoutMs - Maximum time (ms) to wait for the connection. - * @returns `true` if connected within the timeout. - */ - async ensureSignalingConnected(timeoutMs?: number): Promise { - if (this.isAnySignalingConnected()) { - return true; - } - - for (const manager of this.signalingManagers.values()) { - if (await manager.ensureConnected(timeoutMs)) { - return true; - } - } - - return false; - } - - /** - * Send a signaling-level message (with `from` and `timestamp` auto-populated). - * - * @param message - The signaling message payload (excluding `from` / `timestamp`). - */ - sendSignalingMessage(message: Omit): void { - const targetPeerId = message.to; - - if (targetPeerId) { - const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId); - - if (targetSignalUrl) { - const targetManager = this.ensureSignalingManager(targetSignalUrl); - - targetManager.sendSignalingMessage(message, this._localPeerId()); - return; - } - } - - const connectedManagers = this.getConnectedSignalingManagers(); - - if (connectedManagers.length === 0) { - this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), { - type: message.type - }); - - return; - } - - for (const { manager } of connectedManagers) { - manager.sendSignalingMessage(message, this._localPeerId()); - } - } - - /** - * Send a raw JSON payload through the signaling WebSocket. - * - * @param message - Arbitrary JSON message. - */ - sendRawMessage(message: Record): void { - const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null; - - if (targetPeerId) { - const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId); - - if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) { - return; - } - } - - const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null; - - if (serverId) { - const serverSignalUrl = this.serverSignalingUrlMap.get(serverId); - - if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) { - return; - } - } - - const connectedManagers = this.getConnectedSignalingManagers(); - - if (connectedManagers.length === 0) { - this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), { - type: typeof message['type'] === 'string' ? message['type'] : 'unknown' - }); - - return; - } - - for (const { manager } of connectedManagers) { - manager.sendRawMessage(message); - } - } - - private sendRawMessageToSignalUrl(signalUrl: string, message: Record): boolean { - const manager = this.signalingManagers.get(signalUrl); - - if (!manager) { - return false; - } - - manager.sendRawMessage(message); - return true; - } - - /** - * Track the currently-active server ID (for server-scoped operations). - * - * @param serverId - The server to mark as active. - */ - setCurrentServer(serverId: string): void { - this.activeServerId = serverId; - } - - /** The server ID currently being viewed / active, or `null`. */ - get currentServerId(): string | null { - return this.activeServerId; - } - - /** The last signaling URL used by the client, if any. */ - getCurrentSignalingUrl(): string | null { - if (this.activeServerId) { - const activeServerSignalUrl = this.serverSignalingUrlMap.get(this.activeServerId); - - if (activeServerSignalUrl) { - return activeServerSignalUrl; - } - } - - return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null; - } - - /** - * Send an identify message to the signaling server. - * - * The credentials are cached so they can be replayed after a reconnect. - * - * @param oderId - The user's unique order/peer ID. - * @param displayName - The user's display name. - */ - identify(oderId: string, displayName: string, signalUrl?: string): void { - const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME; - - this.lastIdentifyCredentials = { oderId, - displayName: normalizedDisplayName }; - - const identifyMessage = { - type: SIGNALING_TYPE_IDENTIFY, - oderId, - displayName: normalizedDisplayName - }; - - if (signalUrl) { - this.sendRawMessageToSignalUrl(signalUrl, identifyMessage); - return; - } - - const connectedManagers = this.getConnectedSignalingManagers(); - - if (connectedManagers.length === 0) { - return; - } - - for (const { manager } of connectedManagers) { - manager.sendRawMessage(identifyMessage); - } - } - - /** - * Join a server (room) on the signaling server. - * - * @param roomId - The server / room ID to join. - * @param userId - The local user ID. - */ - joinRoom(roomId: string, userId: string, signalUrl?: string): void { - const resolvedSignalUrl = signalUrl - ?? this.serverSignalingUrlMap.get(roomId) - ?? this.getCurrentSignalingUrl(); - - if (!resolvedSignalUrl) { - this.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId }); - return; - } - - this.serverSignalingUrlMap.set(roomId, resolvedSignalUrl); - this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, { - serverId: roomId, - userId - }); - - this.getOrCreateMemberServerSet(resolvedSignalUrl).add(roomId); - this.sendRawMessageToSignalUrl(resolvedSignalUrl, { - type: SIGNALING_TYPE_JOIN_SERVER, - serverId: roomId - }); - } - - /** - * Switch to a different server. If already a member, sends a view event; - * otherwise joins the server. - * - * @param serverId - The target server ID. - * @param userId - The local user ID. - */ - switchServer(serverId: string, userId: string, signalUrl?: string): void { - const resolvedSignalUrl = signalUrl - ?? this.serverSignalingUrlMap.get(serverId) - ?? this.getCurrentSignalingUrl(); - - if (!resolvedSignalUrl) { - this.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId }); - return; - } - - this.serverSignalingUrlMap.set(serverId, resolvedSignalUrl); - this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, { - serverId, - userId - }); - - const memberServerIds = this.getOrCreateMemberServerSet(resolvedSignalUrl); - - if (memberServerIds.has(serverId)) { - this.sendRawMessageToSignalUrl(resolvedSignalUrl, { - type: SIGNALING_TYPE_VIEW_SERVER, - serverId - }); - - this.logger.info('Viewed server (already joined)', { - serverId, - signalUrl: resolvedSignalUrl, - userId, - voiceConnected: this._isVoiceConnected() - }); - } else { - memberServerIds.add(serverId); - this.sendRawMessageToSignalUrl(resolvedSignalUrl, { - type: SIGNALING_TYPE_JOIN_SERVER, - serverId - }); - - this.logger.info('Joined new server via switch', { - serverId, - signalUrl: resolvedSignalUrl, - userId, - voiceConnected: this._isVoiceConnected() - }); - } - } - - /** - * Leave one or all servers. - * - * If `serverId` is provided, leaves only that server. - * Otherwise leaves every joined server and performs a full cleanup. - * - * @param serverId - Optional server to leave; omit to leave all. - */ - leaveRoom(serverId?: string): void { - if (serverId) { - const resolvedSignalUrl = this.serverSignalingUrlMap.get(serverId); - - if (resolvedSignalUrl) { - this.getOrCreateMemberServerSet(resolvedSignalUrl).delete(serverId); - this.sendRawMessageToSignalUrl(resolvedSignalUrl, { - type: SIGNALING_TYPE_LEAVE_SERVER, - serverId - }); - } else { - this.sendRawMessage({ - type: SIGNALING_TYPE_LEAVE_SERVER, - serverId - }); - - for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { - memberServerIds.delete(serverId); - } - } - - this.serverSignalingUrlMap.delete(serverId); - - this.logger.info('Left server', { serverId }); - - if (this.getJoinedServerCount() === 0) { - this.fullCleanup(); - } - - return; - } - - for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) { - for (const sid of memberServerIds) { - this.sendRawMessageToSignalUrl(signalUrl, { - type: SIGNALING_TYPE_LEAVE_SERVER, - serverId: sid - }); - } - } - - this.memberServerIdsBySignalUrl.clear(); - this.serverSignalingUrlMap.clear(); - this.fullCleanup(); - } - - /** - * Check whether the local client has joined a given server. - * - * @param serverId - The server to check. - */ - hasJoinedServer(serverId: string): boolean { - return this.isJoinedServer(serverId); - } - - /** Returns a read-only set of all currently-joined server IDs. */ - getJoinedServerIds(): ReadonlySet { - const joinedServerIds = new Set(); - - for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { - memberServerIds.forEach((serverId) => joinedServerIds.add(serverId)); - } - - return joinedServerIds; - } - - /** - * Broadcast a {@link ChatEvent} to every connected peer. - * - * @param event - The chat event to send. - */ - broadcastMessage(event: ChatEvent): void { - this.peerManager.broadcastMessage(event); - } - - /** - * Send a {@link ChatEvent} to a specific peer. - * - * @param peerId - The target peer ID. - * @param event - The chat event to send. - */ - sendToPeer(peerId: string, event: ChatEvent): void { - this.peerManager.sendToPeer(peerId, event); - } - - syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { - const nextDesiredPeers = new Set( - peerIds.filter((peerId): peerId is string => !!peerId) - ); - - if (!enabled) { - this.remoteScreenShareRequestsEnabled = false; - this.desiredRemoteScreenSharePeers.clear(); - this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]); - return; - } - - this.remoteScreenShareRequestsEnabled = true; - - for (const activePeerId of [...this.activeRemoteScreenSharePeers]) { - if (!nextDesiredPeers.has(activePeerId)) { - this.stopRemoteScreenShares([activePeerId]); - } - } - - this.desiredRemoteScreenSharePeers.clear(); - nextDesiredPeers.forEach((peerId) => this.desiredRemoteScreenSharePeers.add(peerId)); - this.requestRemoteScreenShares([...nextDesiredPeers]); - } - - /** - * Send a {@link ChatEvent} to a peer with back-pressure awareness. - * - * @param peerId - The target peer ID. - * @param event - The chat event to send. - */ - async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { - return this.peerManager.sendToPeerBuffered(peerId, event); - } - - /** Returns an array of currently-connected peer IDs. */ - getConnectedPeers(): string[] { - return this.peerManager.getConnectedPeerIds(); - } - - /** - * Get the composite remote {@link MediaStream} for a connected peer. - * - * @param peerId - The remote peer whose stream to retrieve. - * @returns The stream, or `null` if the peer has no active stream. - */ - getRemoteStream(peerId: string): MediaStream | null { - return this.peerManager.remotePeerStreams.get(peerId) ?? null; - } - - /** - * Get the remote voice-only stream for a connected peer. - * - * @param peerId - The remote peer whose voice stream to retrieve. - * @returns The stream, or `null` if the peer has no active voice audio. - */ - getRemoteVoiceStream(peerId: string): MediaStream | null { - return this.peerManager.remotePeerVoiceStreams.get(peerId) ?? null; - } - - /** - * Get the remote screen-share stream for a connected peer. - * - * This contains the screen video track and any audio track that belongs to - * the screen share itself, not the peer's normal voice-chat audio. - * - * @param peerId - The remote peer whose screen-share stream to retrieve. - * @returns The stream, or `null` if the peer has no active screen share. - */ - getRemoteScreenShareStream(peerId: string): MediaStream | null { - return this.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null; - } - - /** - * Get the current local media stream (microphone audio). - * - * @returns The local {@link MediaStream}, or `null` if voice is not active. - */ - getLocalStream(): MediaStream | null { - return this.mediaManager.getLocalStream(); - } - - /** - * Get the raw local microphone stream before gain / RNNoise processing. - * - * @returns The raw microphone {@link MediaStream}, or `null` if voice is not active. - */ - getRawMicStream(): MediaStream | null { - return this.mediaManager.getRawMicStream(); - } - - /** - * Request microphone access and start sending audio to all peers. - * - * @returns The captured local {@link MediaStream}. - */ - async enableVoice(): Promise { - const stream = await this.mediaManager.enableVoice(); - - this.syncMediaSignals(); - return stream; - } - - /** Stop local voice capture and remove audio senders from peers. */ - disableVoice(): void { - this.voiceServerId = null; - this.mediaManager.disableVoice(); - this._isVoiceConnected.set(false); - } - - /** - * Inject an externally-obtained media stream as the local voice source. - * - * @param stream - The media stream to use. - */ - async setLocalStream(stream: MediaStream): Promise { - await this.mediaManager.setLocalStream(stream); - this.syncMediaSignals(); - } - - /** - * Toggle the local microphone mute state. - * - * @param muted - Explicit state; if omitted, the current state is toggled. - */ - toggleMute(muted?: boolean): void { - this.mediaManager.toggleMute(muted); - this._isMuted.set(this.mediaManager.getIsMicMuted()); - } - - /** - * Toggle self-deafen (suppress incoming audio playback). - * - * @param deafened - Explicit state; if omitted, the current state is toggled. - */ - toggleDeafen(deafened?: boolean): void { - this.mediaManager.toggleDeafen(deafened); - this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); - } - - /** - * Toggle RNNoise noise reduction on the local microphone. - * - * When enabled, the raw mic audio is routed through an AudioWorklet - * that applies neural-network noise suppression before being sent - * to peers. - * - * @param enabled - Explicit state; if omitted, the current state is toggled. - */ - async toggleNoiseReduction(enabled?: boolean): Promise { - await this.mediaManager.toggleNoiseReduction(enabled); - this._isNoiseReductionEnabled.set(this.mediaManager.getIsNoiseReductionEnabled()); - } - - /** - * Set the output volume for remote audio playback. - * - * @param volume - Normalised volume (0-1). - */ - setOutputVolume(volume: number): void { - this.mediaManager.setOutputVolume(volume); - } - - /** - * Set the input (microphone) volume. - * - * Adjusts a Web Audio GainNode on the local mic stream so the level - * sent to peers changes in real time without renegotiation. - * - * @param volume - Normalised volume (0-1). - */ - setInputVolume(volume: number): void { - this.mediaManager.setInputVolume(volume); - } - - /** - * Set the maximum audio bitrate for all peer connections. - * - * @param kbps - Target bitrate in kilobits per second. - */ - async setAudioBitrate(kbps: number): Promise { - return this.mediaManager.setAudioBitrate(kbps); - } - - /** - * Apply a predefined latency profile that maps to a specific bitrate. - * - * @param profile - One of `'low'`, `'balanced'`, or `'high'`. - */ - async setLatencyProfile(profile: LatencyProfile): Promise { - return this.mediaManager.setLatencyProfile(profile); - } - - /** - * Start broadcasting voice-presence heartbeats to all peers. - * - * Also marks the given server as the active voice server and closes - * any peer connections that belong to other servers so that audio - * is isolated to the correct voice channel. - * - * @param roomId - The voice channel room ID. - * @param serverId - The voice channel server ID. - */ - startVoiceHeartbeat(roomId?: string, serverId?: string): void { - if (serverId) { - this.voiceServerId = serverId; - } - - this.mediaManager.startVoiceHeartbeat(roomId, serverId); - } - - /** Stop the voice-presence heartbeat. */ - stopVoiceHeartbeat(): void { - this.mediaManager.stopVoiceHeartbeat(); - } - - /** - * Start sharing the screen (or a window) with all connected peers. - * - * @param options - Screen-share capture options. - * @returns The screen-capture {@link MediaStream}. - */ - async startScreenShare(options: ScreenShareStartOptions): Promise { - return await this.screenShareManager.startScreenShare(options); - } - - /** Stop screen sharing and restore microphone audio on all peers. */ - stopScreenShare(): void { - this.screenShareManager.stopScreenShare(); - } - - /** Disconnect from the signaling server and clean up all state. */ - disconnect(): void { - this.leaveRoom(); - this.voiceServerId = null; - this.peerServerMap.clear(); - this.peerSignalingUrlMap.clear(); - this.lastJoinedServerBySignalUrl.clear(); - this.memberServerIdsBySignalUrl.clear(); - this.serverSignalingUrlMap.clear(); - this.mediaManager.stopVoiceHeartbeat(); - this.destroyAllSignalingManagers(); - this._isSignalingConnected.set(false); - this._hasEverConnected.set(false); - this._hasConnectionError.set(false); - this._connectionErrorMessage.set(null); - this.serviceDestroyed$.next(); - } - - /** Alias for {@link disconnect}. */ - disconnectAll(): void { - this.disconnect(); - } - - private fullCleanup(): void { - this.voiceServerId = null; - this.peerServerMap.clear(); - this.peerSignalingUrlMap.clear(); - this.remoteScreenShareRequestsEnabled = false; - this.desiredRemoteScreenSharePeers.clear(); - this.activeRemoteScreenSharePeers.clear(); - this.peerManager.closeAllPeers(); - this._connectedPeers.set([]); - this.mediaManager.disableVoice(); - this._isVoiceConnected.set(false); - this.screenShareManager.stopScreenShare(); - this._isScreenSharing.set(false); - this._screenStreamSignal.set(null); - this._isScreenShareRemotePlaybackSuppressed.set(false); - this._forceDefaultRemotePlaybackOutput.set(false); - } - - /** Synchronise Angular signals from the MediaManager's internal state. */ - private syncMediaSignals(): void { - this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive()); - this._isMuted.set(this.mediaManager.getIsMicMuted()); - this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); - } - - /** Returns true if a peer connection is still alive enough to finish negotiating. */ - private canReusePeerConnection(peer: import('./webrtc').PeerData | undefined): boolean { - if (!peer) - return false; - - const connState = peer.connection?.connectionState; - - return connState !== 'closed' && connState !== 'failed'; - } - - private handlePeerControlMessage(event: ChatEvent): void { - if (!event.fromPeerId) { - return; - } - - if (event.type === P2P_TYPE_SCREEN_STATE && event.isScreenSharing === false) { - this.peerManager.clearRemoteScreenShareStream(event.fromPeerId); - return; - } - - if (event.type === P2P_TYPE_SCREEN_SHARE_REQUEST) { - this.screenShareManager.requestScreenShareForPeer(event.fromPeerId); - return; - } - - if (event.type === P2P_TYPE_SCREEN_SHARE_STOP) { - this.screenShareManager.stopScreenShareForPeer(event.fromPeerId); - } - } - - private requestRemoteScreenShares(peerIds: string[]): void { - const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds()); - - for (const peerId of peerIds) { - if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) { - continue; - } - - this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST }); - this.activeRemoteScreenSharePeers.add(peerId); - } - } - - private stopRemoteScreenShares(peerIds: string[]): void { - const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds()); - - for (const peerId of peerIds) { - if (this.activeRemoteScreenSharePeers.has(peerId) && connectedPeerIds.has(peerId)) { - this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_STOP }); - } - - this.activeRemoteScreenSharePeers.delete(peerId); - this.peerManager.clearRemoteScreenShareStream(peerId); - } - } - - private destroyAllSignalingManagers(): void { - for (const subscriptions of this.signalingSubscriptions.values()) { - for (const subscription of subscriptions) { - subscription.unsubscribe(); - } - } - - for (const manager of this.signalingManagers.values()) { - manager.destroy(); - } - - this.signalingSubscriptions.clear(); - this.signalingManagers.clear(); - this.signalingConnectionStates.clear(); - } - - ngOnDestroy(): void { - this.disconnect(); - this.serviceDestroyed$.complete(); - this.peerManager.destroy(); - this.mediaManager.destroy(); - this.screenShareManager.destroy(); - } -} diff --git a/src/app/core/services/webrtc/index.ts b/src/app/core/services/webrtc/index.ts deleted file mode 100644 index 5a1efa6..0000000 --- a/src/app/core/services/webrtc/index.ts +++ /dev/null @@ -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'; diff --git a/src/app/core/services/webrtc/screen-share-platforms/shared.ts b/src/app/core/services/webrtc/screen-share-platforms/shared.ts deleted file mode 100644 index d50a6e6..0000000 --- a/src/app/core/services/webrtc/screen-share-platforms/shared.ts +++ /dev/null @@ -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; - prepareLinuxScreenShareAudioRouting?: () => Promise; - activateLinuxScreenShareAudioRouting?: () => Promise; - deactivateLinuxScreenShareAudioRouting?: () => Promise; - startLinuxScreenShareMonitorCapture?: () => Promise; - stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise; - 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; -}; diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.scss b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.scss deleted file mode 100644 index 980c896..0000000 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.scss +++ /dev/null @@ -1,29 +0,0 @@ -.chat-textarea { - --textarea-bg: hsl(40deg 3.7% 15.9% / 87%); - background: var(--textarea-bg); - height: 62px; - min-height: 62px; - max-height: 520px; - overflow-y: hidden; - resize: none; - transition: height 0.12s ease; - - &.ctrl-resize { - resize: vertical; - } -} - -.send-btn { - opacity: 0; - pointer-events: none; - transform: scale(0.85); - transition: - opacity 0.2s ease, - transform 0.2s ease; - - &.visible { - opacity: 1; - pointer-events: auto; - transform: scale(1); - } -} diff --git a/src/app/features/settings/settings-modal/general-settings/general-settings.component.html b/src/app/features/settings/settings-modal/general-settings/general-settings.component.html deleted file mode 100644 index 29c2ed1..0000000 --- a/src/app/features/settings/settings-modal/general-settings/general-settings.component.html +++ /dev/null @@ -1,47 +0,0 @@ -
-
-
- -

Application

-
- -
-
-
-

Launch on system startup

- - @if (isElectron) { -

Automatically start MetoYou when you sign in

- } @else { -

This setting is only available in the desktop app.

- } -
- - -
-
-
-
diff --git a/src/app/features/voice/screen-share-workspace/screen-share-workspace.models.ts b/src/app/features/voice/screen-share-workspace/screen-share-workspace.models.ts deleted file mode 100644 index 834afb5..0000000 --- a/src/app/features/voice/screen-share-workspace/screen-share-workspace.models.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { User } from '../../../core/models'; - -export interface ScreenShareWorkspaceStreamItem { - id: string; - peerKey: string; - user: User; - stream: MediaStream; - isLocal: boolean; -} diff --git a/angular.json b/toju-app/angular.json similarity index 74% rename from angular.json rename to toju-app/angular.json index cc3f79d..f182026 100644 --- a/angular.json +++ b/toju-app/angular.json @@ -1,5 +1,5 @@ { - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "$schema": "../node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { "packageManager": "npm", @@ -62,27 +62,28 @@ ], "styles": [ "src/styles.scss", - "node_modules/prismjs/themes/prism-okaidia.css" + "../node_modules/prismjs/themes/prism-okaidia.css" ], "scripts": [ - "node_modules/prismjs/prism.js", - "node_modules/prismjs/components/prism-markup.min.js", - "node_modules/prismjs/components/prism-clike.min.js", - "node_modules/prismjs/components/prism-javascript.min.js", - "node_modules/prismjs/components/prism-typescript.min.js", - "node_modules/prismjs/components/prism-css.min.js", - "node_modules/prismjs/components/prism-scss.min.js", - "node_modules/prismjs/components/prism-json.min.js", - "node_modules/prismjs/components/prism-bash.min.js", - "node_modules/prismjs/components/prism-markdown.min.js", - "node_modules/prismjs/components/prism-yaml.min.js", - "node_modules/prismjs/components/prism-python.min.js", - "node_modules/prismjs/components/prism-csharp.min.js" + "../node_modules/prismjs/prism.js", + "../node_modules/prismjs/components/prism-markup.min.js", + "../node_modules/prismjs/components/prism-clike.min.js", + "../node_modules/prismjs/components/prism-javascript.min.js", + "../node_modules/prismjs/components/prism-typescript.min.js", + "../node_modules/prismjs/components/prism-css.min.js", + "../node_modules/prismjs/components/prism-scss.min.js", + "../node_modules/prismjs/components/prism-json.min.js", + "../node_modules/prismjs/components/prism-bash.min.js", + "../node_modules/prismjs/components/prism-markdown.min.js", + "../node_modules/prismjs/components/prism-yaml.min.js", + "../node_modules/prismjs/components/prism-python.min.js", + "../node_modules/prismjs/components/prism-csharp.min.js" ], "allowedCommonJsDependencies": [ "simple-peer", "uuid" - ] + ], + "outputPath": "../dist/client" }, "configurations": { "production": { @@ -96,7 +97,7 @@ { "type": "initial", "maximumWarning": "1MB", - "maximumError": "2MB" + "maximumError": "2.1MB" }, { "type": "anyComponentStyle", diff --git a/public/favicon.ico b/toju-app/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to toju-app/public/favicon.ico diff --git a/public/rnnoise-worklet.js b/toju-app/public/rnnoise-worklet.js similarity index 100% rename from public/rnnoise-worklet.js rename to toju-app/public/rnnoise-worklet.js diff --git a/public/web.config b/toju-app/public/web.config similarity index 100% rename from public/web.config rename to toju-app/public/web.config diff --git a/src/app/app.config.ts b/toju-app/src/app/app.config.ts similarity index 94% rename from src/app/app.config.ts rename to toju-app/src/app/app.config.ts index 4b0c763..2b76c41 100644 --- a/src/app/app.config.ts +++ b/toju-app/src/app/app.config.ts @@ -13,6 +13,7 @@ import { routes } from './app.routes'; import { messagesReducer } from './store/messages/messages.reducer'; import { usersReducer } from './store/users/users.reducer'; import { roomsReducer } from './store/rooms/rooms.reducer'; +import { NotificationsEffects } from './domains/notifications'; import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { UsersEffects } from './store/users/users.effects'; @@ -32,6 +33,7 @@ export const appConfig: ApplicationConfig = { rooms: roomsReducer }), provideEffects([ + NotificationsEffects, MessagesEffects, MessagesSyncEffects, UsersEffects, diff --git a/src/app/app.html b/toju-app/src/app/app.html similarity index 100% rename from src/app/app.html rename to toju-app/src/app/app.html diff --git a/src/app/app.routes.ts b/toju-app/src/app/app.routes.ts similarity index 66% rename from src/app/app.routes.ts rename to toju-app/src/app/app.routes.ts index 1e75375..e4640ce 100644 --- a/src/app/app.routes.ts +++ b/toju-app/src/app/app.routes.ts @@ -10,22 +10,22 @@ export const routes: Routes = [ { path: 'login', loadComponent: () => - import('./features/auth/login/login.component').then((module) => module.LoginComponent) + import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent) }, { path: 'register', loadComponent: () => - import('./features/auth/register/register.component').then((module) => module.RegisterComponent) + import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent) }, { path: 'invite/:inviteId', loadComponent: () => - import('./features/invite/invite.component').then((module) => module.InviteComponent) + import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent) }, { path: 'search', loadComponent: () => - import('./features/server-search/server-search.component').then( + import('./domains/server-directory/feature/server-search/server-search.component').then( (module) => module.ServerSearchComponent ) }, diff --git a/src/app/app.scss b/toju-app/src/app/app.scss similarity index 100% rename from src/app/app.scss rename to toju-app/src/app/app.scss diff --git a/src/app/app.ts b/toju-app/src/app/app.ts similarity index 85% rename from src/app/app.ts rename to toju-app/src/app/app.ts index e510bc6..81b51f8 100644 --- a/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -14,16 +14,18 @@ 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 { NotificationsFacade } from './domains/notifications'; 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'; +import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; @@ -36,15 +38,6 @@ import { STORAGE_KEY_LAST_VISITED_ROUTE } from './core/constants'; -interface DeepLinkElectronApi { - consumePendingDeepLink?: () => Promise; - onDeepLinkReceived?: (listener: (url: string) => void) => () => void; -} - -type DeepLinkWindow = Window & { - electronAPI?: DeepLinkElectronApi; -}; - @Component({ selector: 'app-root', imports: [ @@ -68,11 +61,13 @@ export class App implements OnInit, OnDestroy { private databaseService = inject(DatabaseService); private router = inject(Router); - private servers = inject(ServerDirectoryService); + private servers = inject(ServerDirectoryFacade); + private notifications = inject(NotificationsFacade); 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']) @@ -91,6 +86,8 @@ export class App implements OnInit, OnDestroy { await this.timeSync.syncWithEndpoint(apiBase); } catch {} + await this.notifications.initialize(); + await this.setupDesktopDeepLinks(); this.store.dispatch(UsersActions.loadCurrentUser()); @@ -155,7 +152,7 @@ export class App implements OnInit, OnDestroy { } private async setupDesktopDeepLinks(): Promise { - const electronApi = this.getDeepLinkElectronApi(); + const electronApi = this.electronBridge.getApi(); if (!electronApi) { return; @@ -186,12 +183,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' || diff --git a/src/app/core/constants.ts b/toju-app/src/app/core/constants.ts similarity index 89% rename from src/app/core/constants.ts rename to toju-app/src/app/core/constants.ts index 2a72df2..5ba6e31 100644 --- a/src/app/core/constants.ts +++ b/toju-app/src/app/core/constants.ts @@ -1,6 +1,7 @@ export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId'; export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute'; export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; +export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; diff --git a/src/app/core/helpers/debugging-helpers.ts b/toju-app/src/app/core/helpers/debugging-helpers.ts similarity index 100% rename from src/app/core/helpers/debugging-helpers.ts rename to toju-app/src/app/core/helpers/debugging-helpers.ts diff --git a/src/app/core/helpers/room-ban.helpers.ts b/toju-app/src/app/core/helpers/room-ban.helpers.ts similarity index 100% rename from src/app/core/helpers/room-ban.helpers.ts rename to toju-app/src/app/core/helpers/room-ban.helpers.ts diff --git a/src/app/core/models.ts b/toju-app/src/app/core/models.ts similarity index 100% rename from src/app/core/models.ts rename to toju-app/src/app/core/models.ts diff --git a/src/app/core/models/debugging.models.ts b/toju-app/src/app/core/models/debugging.models.ts similarity index 100% rename from src/app/core/models/debugging.models.ts rename to toju-app/src/app/core/models/debugging.models.ts diff --git a/toju-app/src/app/core/models/index.ts b/toju-app/src/app/core/models/index.ts new file mode 100644 index 0000000..8f58525 --- /dev/null +++ b/toju-app/src/app/core/models/index.ts @@ -0,0 +1,52 @@ +/** + * Transitional compatibility barrel. + * + * All business types now live in `src/app/shared-kernel/` (organised by concept) + * or in their owning domain. This file re-exports everything so existing + * `import { X } from 'core/models'` lines keep working while the codebase + * migrates to direct shared-kernel imports. + * + * NEW CODE should import from `@shared-kernel` or the owning domain barrel + * instead of this file. + */ + +export type { + User, + UserStatus, + UserRole, + RoomMember +} from '../../shared-kernel'; + +export type { + Room, + RoomSettings, + RoomPermissions, + Channel, + ChannelType +} from '../../shared-kernel'; + +export type { Message, Reaction } from '../../shared-kernel'; +export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel'; + +export type { BanEntry } from '../../shared-kernel'; + +export type { VoiceState, ScreenShareState } from '../../shared-kernel'; + +export type { + ChatEventBase, + ChatEventType, + ChatEvent, + ChatInventoryItem +} from '../../shared-kernel'; + +export type { + SignalingMessage, + SignalingMessageType +} from '../../shared-kernel'; + +export type { + ChatAttachmentAnnouncement, + ChatAttachmentMeta +} from '../../shared-kernel'; + +export type { ServerInfo } from '../../domains/server-directory'; diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts new file mode 100644 index 0000000..defb1fc --- /dev/null +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -0,0 +1,174 @@ +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 DesktopUpdateServerHealthSnapshot { + manifestUrl: string | null; + 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; + closeToTray: boolean; + hardwareAcceleration: boolean; + manifestUrls: string[]; + preferredVersion: string | null; + runtimeHardwareAcceleration: boolean; + restartRequired: boolean; +} + +export interface DesktopSettingsPatch { + autoUpdateMode?: AutoUpdateMode; + autoStart?: boolean; + closeToTray?: boolean; + hardwareAcceleration?: boolean; + manifestUrls?: string[]; + preferredVersion?: string | null; + vaapiVideoEncode?: boolean; +} + +export interface DesktopNotificationPayload { + body: string; + requestAttention: boolean; + title: string; +} + +export interface WindowStateSnapshot { + isFocused: boolean; + isMinimized: 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; + getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; + prepareLinuxScreenShareAudioRouting: () => Promise; + activateLinuxScreenShareAudioRouting: () => Promise; + deactivateLinuxScreenShareAudioRouting: () => Promise; + startLinuxScreenShareMonitorCapture: () => Promise; + stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise; + onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; + onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; + getAppDataPath: () => Promise; + consumePendingDeepLink: () => Promise; + getDesktopSettings: () => Promise; + showDesktopNotification: (payload: DesktopNotificationPayload) => Promise; + requestWindowAttention: () => Promise; + clearWindowAttention: () => Promise; + onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void; + getAutoUpdateState: () => Promise; + getAutoUpdateServerHealth: (serverUrl: string) => Promise; + configureAutoUpdateContext: (context: Partial) => Promise; + checkForAppUpdates: () => Promise; + restartToApplyUpdate: () => Promise; + onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void; + setDesktopSettings: (patch: DesktopSettingsPatch) => Promise; + relaunchApp: () => Promise; + onDeepLinkReceived: (listener: (url: string) => void) => () => void; + readClipboardFiles: () => Promise; + readFile: (filePath: string) => Promise; + writeFile: (filePath: string, data: string) => Promise; + saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; + fileExists: (filePath: string) => Promise; + deleteFile: (filePath: string) => Promise; + ensureDir: (dirPath: string) => Promise; + command: (command: ElectronCommand) => Promise; + query: (query: ElectronQuery) => Promise; +} + +export type ElectronWindow = Window & { + electronAPI?: ElectronApi; +}; diff --git a/toju-app/src/app/core/platform/electron/electron-bridge.service.ts b/toju-app/src/app/core/platform/electron/electron-bridge.service.ts new file mode 100644 index 0000000..0cf0274 --- /dev/null +++ b/toju-app/src/app/core/platform/electron/electron-bridge.service.ts @@ -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; + } +} diff --git a/toju-app/src/app/core/platform/electron/get-electron-api.ts b/toju-app/src/app/core/platform/electron/get-electron-api.ts new file mode 100644 index 0000000..9d9839c --- /dev/null +++ b/toju-app/src/app/core/platform/electron/get-electron-api.ts @@ -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; +} diff --git a/src/app/core/services/external-link.service.ts b/toju-app/src/app/core/platform/external-link.service.ts similarity index 68% rename from src/app/core/services/external-link.service.ts rename to toju-app/src/app/core/platform/external-link.service.ts index 1c73289..6498270 100644 --- a/src/app/core/services/external-link.service.ts +++ b/toju-app/src/app/core/platform/external-link.service.ts @@ -1,13 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { PlatformService } from './platform.service'; - -interface ExternalLinkElectronApi { - openExternal?: (url: string) => Promise; -} - -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; diff --git a/toju-app/src/app/core/platform/index.ts b/toju-app/src/app/core/platform/index.ts new file mode 100644 index 0000000..0165723 --- /dev/null +++ b/toju-app/src/app/core/platform/index.ts @@ -0,0 +1,2 @@ +export * from './platform.service'; +export * from './external-link.service'; diff --git a/toju-app/src/app/core/platform/platform.service.ts b/toju-app/src/app/core/platform/platform.service.ts new file mode 100644 index 0000000..549634f --- /dev/null +++ b/toju-app/src/app/core/platform/platform.service.ts @@ -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; + } +} diff --git a/toju-app/src/app/core/realtime/index.ts b/toju-app/src/app/core/realtime/index.ts new file mode 100644 index 0000000..ee16ace --- /dev/null +++ b/toju-app/src/app/core/realtime/index.ts @@ -0,0 +1,8 @@ +/** + * Transitional application-facing boundary over the shared realtime runtime. + * Keep business domains depending on this technical API rather than reaching + * into low-level infrastructure implementations directly. + */ +export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service'; +export * from '../../infrastructure/realtime/realtime.constants'; +export * from '../../infrastructure/realtime/realtime.types'; diff --git a/src/app/core/services/debugging.service.ts b/toju-app/src/app/core/services/debugging.service.ts similarity index 100% rename from src/app/core/services/debugging.service.ts rename to toju-app/src/app/core/services/debugging.service.ts diff --git a/src/app/core/services/debugging/debugging-network-snapshot.builder.ts b/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts similarity index 98% rename from src/app/core/services/debugging/debugging-network-snapshot.builder.ts rename to toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts index e03027b..308cdc1 100644 --- a/src/app/core/services/debugging/debugging-network-snapshot.builder.ts +++ b/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts @@ -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, @@ -433,7 +433,7 @@ class DebugNetworkSnapshotBuilder { } } - if (type === 'screen-state') { + if (type === 'screen-state' || type === 'camera-state') { const subjectNode = direction === 'outbound' ? this.ensureLocalNetworkNode( state, @@ -442,12 +442,14 @@ class DebugNetworkSnapshotBuilder { this.getPayloadString(payload, 'displayName') ) : peerNode; - const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing'); + const isStreaming = type === 'screen-state' + ? this.getPayloadBoolean(payload, 'isScreenSharing') + : this.getPayloadBoolean(payload, 'isCameraEnabled'); - if (isScreenSharing !== null) { - subjectNode.isStreaming = isScreenSharing; + if (isStreaming !== null) { + subjectNode.isStreaming = isStreaming; - if (!isScreenSharing) + if (!isStreaming) subjectNode.streams.video = 0; } } diff --git a/src/app/core/services/debugging/debugging.constants.ts b/toju-app/src/app/core/services/debugging/debugging.constants.ts similarity index 100% rename from src/app/core/services/debugging/debugging.constants.ts rename to toju-app/src/app/core/services/debugging/debugging.constants.ts diff --git a/src/app/core/services/debugging/debugging.service.ts b/toju-app/src/app/core/services/debugging/debugging.service.ts similarity index 100% rename from src/app/core/services/debugging/debugging.service.ts rename to toju-app/src/app/core/services/debugging/debugging.service.ts diff --git a/src/app/core/services/debugging/index.ts b/toju-app/src/app/core/services/debugging/index.ts similarity index 100% rename from src/app/core/services/debugging/index.ts rename to toju-app/src/app/core/services/debugging/index.ts diff --git a/src/app/core/services/desktop-app-update.service.ts b/toju-app/src/app/core/services/desktop-app-update.service.ts similarity index 69% rename from src/app/core/services/desktop-app-update.service.ts rename to toju-app/src/app/core/services/desktop-app-update.service.ts index 5648a2b..1f05791 100644 --- a/src/app/core/services/desktop-app-update.service.ts +++ b/toju-app/src/app/core/services/desktop-app-update.service.ts @@ -5,70 +5,17 @@ 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; - configureAutoUpdateContext?: (context: Partial) => Promise; - getAutoUpdateState?: () => Promise; - onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void; - restartToApplyUpdate?: () => Promise; - setDesktopSettings?: (patch: { - autoUpdateMode?: AutoUpdateMode; - manifestUrls?: string[]; - preferredVersion?: string | null; - }) => Promise; -} - -interface ServerHealthResponse { - releaseManifestUrl?: string; - serverVersion?: string; -} +import { PlatformService } from '../platform'; +import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory'; +import { + type AutoUpdateMode, + type DesktopUpdateServerContext, + type DesktopUpdateServerHealthSnapshot, + type DesktopUpdateServerVersionStatus, + type DesktopUpdateState, + type ElectronApi +} from '../platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../platform/electron/electron-bridge.service'; interface ServerHealthSnapshot { endpointId: string; @@ -77,12 +24,7 @@ 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; function createInitialState(): DesktopUpdateState { return { @@ -153,7 +95,8 @@ export class DesktopAppUpdateService { readonly state = signal(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; @@ -344,30 +287,23 @@ export class DesktopAppUpdateService { private async readServerHealth(endpoint: ServerEndpoint): Promise { const sanitizedServerUrl = endpoint.url.replace(/\/+$/, ''); + const api = this.getElectronApi(); + + if (!api?.getAutoUpdateServerHealth) { + return { + endpointId: endpoint.id, + manifestUrl: null, + serverVersion: null, + serverVersionStatus: 'unavailable' + }; + } try { - const response = await fetch(`${sanitizedServerUrl}/api/health`, { - method: 'GET', - signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS) - }); - - if (!response.ok) { - return { - endpointId: endpoint.id, - manifestUrl: null, - serverVersion: null, - serverVersionStatus: 'unavailable' - }; - } - - const payload = await response.json() as ServerHealthResponse; - const serverVersion = normalizeOptionalString(payload.serverVersion); + const payload = await api.getAutoUpdateServerHealth(sanitizedServerUrl); return { endpointId: endpoint.id, - manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl), - serverVersion, - serverVersionStatus: serverVersion ? 'reported' : 'missing' + ...this.normalizeHealthSnapshot(payload) }; } catch { return { @@ -379,6 +315,22 @@ export class DesktopAppUpdateService { } } + private normalizeHealthSnapshot( + snapshot: DesktopUpdateServerHealthSnapshot + ): Omit { + const serverVersion = normalizeOptionalString(snapshot.serverVersion); + + return { + manifestUrl: normalizeOptionalHttpUrl(snapshot.manifestUrl), + serverVersion, + serverVersionStatus: serverVersion + ? snapshot.serverVersionStatus + : snapshot.serverVersionStatus === 'reported' + ? 'missing' + : snapshot.serverVersionStatus + }; + } + private async pushContext(context: Partial): Promise { const api = this.getElectronApi(); @@ -393,9 +345,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(); } } diff --git a/toju-app/src/app/core/services/index.ts b/toju-app/src/app/core/services/index.ts new file mode 100644 index 0000000..fe3b2af --- /dev/null +++ b/toju-app/src/app/core/services/index.ts @@ -0,0 +1,4 @@ +export * from './notification-audio.service'; +export * from '../models/debugging.models'; +export * from './debugging/debugging.service'; +export * from './settings-modal.service'; diff --git a/src/app/core/services/notification-audio.service.ts b/toju-app/src/app/core/services/notification-audio.service.ts similarity index 80% rename from src/app/core/services/notification-audio.service.ts rename to toju-app/src/app/core/services/notification-audio.service.ts index 59781f4..bcb3baf 100644 --- a/src/app/core/services/notification-audio.service.ts +++ b/toju-app/src/app/core/services/notification-audio.service.ts @@ -13,7 +13,7 @@ export enum AppSound { } /** Path prefix for audio assets (served from the `assets/audio/` folder). */ -const AUDIO_BASE = '/assets/audio'; +const AUDIO_BASE = 'assets/audio'; /** File extension used for all sound-effect assets. */ const AUDIO_EXT = 'wav'; /** localStorage key for persisting notification volume. */ @@ -36,6 +36,8 @@ export class NotificationAudioService { /** Pre-loaded audio buffers keyed by {@link AppSound}. */ private readonly cache = new Map(); + private readonly sources = new Map(); + /** Reactive notification volume (0 - 1), persisted to localStorage. */ readonly notificationVolume = signal(this.loadVolume()); @@ -46,13 +48,22 @@ export class NotificationAudioService { /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ private preload(): void { for (const sound of Object.values(AppSound)) { - const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`); + const src = this.resolveAudioUrl(sound); + const audio = new Audio(); audio.preload = 'auto'; + audio.src = src; + audio.load(); + + this.sources.set(sound, src); this.cache.set(sound, audio); } } + private resolveAudioUrl(sound: AppSound): string { + return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString(); + } + /** Read persisted volume from localStorage, falling back to the default. */ private loadVolume(): number { try { @@ -96,8 +107,9 @@ export class NotificationAudioService { */ play(sound: AppSound, volumeOverride?: number): void { const cached = this.cache.get(sound); + const src = this.sources.get(sound); - if (!cached) + if (!cached || !src) return; const vol = volumeOverride ?? this.notificationVolume(); @@ -105,12 +117,23 @@ export class NotificationAudioService { if (vol === 0) return; // skip playback when muted + if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) { + cached.load(); + } + // Clone so overlapping plays don't cut each other off. const clone = cached.cloneNode(true) as HTMLAudioElement; + clone.preload = 'auto'; clone.volume = Math.max(0, Math.min(1, vol)); clone.play().catch(() => { - /* swallow autoplay errors */ + const fallback = new Audio(src); + + fallback.preload = 'auto'; + fallback.volume = clone.volume; + fallback.play().catch(() => { + /* swallow autoplay errors */ + }); }); } } diff --git a/src/app/core/services/settings-modal.service.ts b/toju-app/src/app/core/services/settings-modal.service.ts similarity index 76% rename from src/app/core/services/settings-modal.service.ts rename to toju-app/src/app/core/services/settings-modal.service.ts index 6168d34..dcfb525 100644 --- a/src/app/core/services/settings-modal.service.ts +++ b/toju-app/src/app/core/services/settings-modal.service.ts @@ -1,5 +1,16 @@ import { Injectable, signal } from '@angular/core'; -export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions'; + +export type SettingsPage = + | 'general' + | 'network' + | 'notifications' + | 'voice' + | 'updates' + | 'debugging' + | 'server' + | 'members' + | 'bans' + | 'permissions'; @Injectable({ providedIn: 'root' }) export class SettingsModalService { diff --git a/src/app/core/services/time-sync.service.ts b/toju-app/src/app/core/services/time-sync.service.ts similarity index 100% rename from src/app/core/services/time-sync.service.ts rename to toju-app/src/app/core/services/time-sync.service.ts diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md new file mode 100644 index 0000000..673fa87 --- /dev/null +++ b/toju-app/src/app/domains/README.md @@ -0,0 +1,76 @@ +# Domains + +Each folder below is a **bounded context** — a self-contained slice of +business logic with its own models, application services, and (optionally) +infrastructure adapters and UI. + +## Quick reference + +| Domain | Purpose | Public entry point | +|---|---|---| +| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` | +| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` | +| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | +| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | +| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | +| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | +| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` | +| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` | + +## Detailed docs + +The larger domains also keep longer design notes in their own folders: + +- [attachment/README.md](attachment/README.md) +- [auth/README.md](auth/README.md) +- [chat/README.md](chat/README.md) +- [notifications/README.md](notifications/README.md) +- [screen-share/README.md](screen-share/README.md) +- [server-directory/README.md](server-directory/README.md) +- [voice-connection/README.md](voice-connection/README.md) +- [voice-session/README.md](voice-session/README.md) + +## Folder convention + +Every domain follows the same internal layout: + +``` +domains// +├── index.ts # Barrel — the ONLY file outsiders import +├── domain/ # Pure types, interfaces, business rules +│ ├── .models.ts +│ └── .logic.ts # Pure functions (no Angular, no side effects) +├── application/ # Angular services that orchestrate domain logic +│ └── .facade.ts # Public entry point for the domain +├── infrastructure/ # Technical adapters (HTTP, storage, WebSocket) +└── feature/ # Optional: domain-owned UI components / routes + └── settings/ # e.g. settings subpanel owned by this domain +``` + +## Rules + +1. **Import from the barrel.** Outside a domain, always import from + `domains/` (the `index.ts`), never from internal paths. + +2. **No cross-domain imports.** Domain A must never import from Domain B's + internals. Shared types live in `shared-kernel/`. + +3. **Features compose domains.** Top-level `features/` components inject + domain facades and compose their outputs — they never contain business + logic. + +4. **Store slices are application-level.** `store/messages`, `store/rooms`, + `store/users` are global state managed by NgRx. They import from + `shared-kernel` for types and from domain facades for side-effects. + +## Where do I put new code? + +| I want to… | Put it in… | +|---|---| +| Add a new business concept | New folder under `domains/` following the convention above | +| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name | +| Add a UI component for a domain feature | `domains//feature/` or `domains//ui/` | +| Add a settings subpanel | `domains//feature/settings/` | +| Add a top-level page or shell component | `features/` | +| Add persistence logic | `infrastructure/persistence/` or `domains//infrastructure/` | +| Add realtime/WebRTC logic | `infrastructure/realtime/` | diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md new file mode 100644 index 0000000..c69f966 --- /dev/null +++ b/toju-app/src/app/domains/attachment/README.md @@ -0,0 +1,148 @@ +# Attachment Domain + +Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser). + +## Module map + +``` +attachment/ +├── application/ +│ ├── attachment.facade.ts Thin entry point, delegates to manager +│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners +│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel) +│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming +│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage +│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending) +│ +├── domain/ +│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state +│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment +│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB +│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...) +│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages +│ +├── infrastructure/ +│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete) +│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket +│ +└── index.ts Barrel exports +``` + +## Service composition + +The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals). + +```mermaid +graph TD + Facade[AttachmentFacade] + Manager[AttachmentManagerService] + Transfer[AttachmentTransferService] + Transport[AttachmentTransferTransportService] + Persistence[AttachmentPersistenceService] + Store[AttachmentRuntimeStore] + Storage[AttachmentStorageService] + Logic[attachment.logic] + + Facade --> Manager + Manager --> Transfer + Manager --> Persistence + Manager --> Store + Manager --> Logic + Transfer --> Transport + Transfer --> Store + Persistence --> Storage + Persistence --> Store + Storage --> Helpers[attachment-storage.helpers] + + click Facade "application/attachment.facade.ts" "Thin entry point" _blank + click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank + click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank + click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank + click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank + click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank + click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank + click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank + click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank +``` + +## File transfer protocol + +Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one. + +```mermaid +sequenceDiagram + participant S as Sender + participant R as Receiver + + S->>R: file-announce (id, name, size, mimeType) + Note over R: Store metadata in runtime store + Note over R: shouldAutoRequestWhenWatched? + + R->>S: file-request (attachmentId) + Note over S: Look up file in runtime store or on disk + + loop Every 64 KB chunk + S->>R: file-chunk (attachmentId, index, data, progress, speed) + Note over R: Append to chunk buffer + Note over R: Update progress + EWMA speed + end + + Note over R: All chunks received + Note over R: Reassemble blob + Note over R: shouldPersistDownloadedAttachment? Save to disk +``` + +### Failure handling + +If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress. + +```mermaid +sequenceDiagram + participant R as Receiver + participant P1 as Peer A + participant P2 as Peer B + + R->>P1: file-request + P1->>R: file-not-found + Note over R: Try next peer + R->>P2: file-request + P2->>R: file-chunk (1/N) + P2->>R: file-chunk (2/N) + P2->>R: file-chunk (N/N) + Note over R: Transfer complete +``` + +## Auto-download rules + +When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic: + +| Condition | Auto-download? | +|---|---| +| Image or video, size <= 10 MB | Yes | +| Image or video, size > 10 MB | No | +| Non-media file | No | + +The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`. + +## Persistence + +On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket: + +``` +{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?} +``` + +Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other. + +`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only. + +## Runtime store + +`AttachmentRuntimeStore` is a signal-based in-memory store using `Map` instances for: + +- **attachments**: all known attachments keyed by ID +- **chunks**: incoming chunk buffers during active transfers +- **pendingRequests**: outbound requests waiting for a response +- **cancellations**: IDs of transfers the user cancelled + +Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service. diff --git a/toju-app/src/app/domains/attachment/application/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/attachment-manager.service.ts new file mode 100644 index 0000000..fc4e730 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/attachment-manager.service.ts @@ -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 { + 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 { + await this.persistence.deleteForMessage(messageId); + } + + getAttachmentMetasForMessages(messageIds: string[]): Record { + return this.transfer.getAttachmentMetasForMessages(messageIds); + } + + registerSyncedAttachments( + attachmentMap: Record, + messageRoomIds?: Record + ): 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 { + 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 { + 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 { + await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file); + } + + private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise { + 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; + } +} diff --git a/toju-app/src/app/domains/attachment/application/attachment-persistence.service.ts b/toju-app/src/app/domains/attachment/application/attachment-persistence.service.ts new file mode 100644 index 0000000..329e38e --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/attachment-persistence.service.ts @@ -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 { + 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(); + + 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 { + 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 { + 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 { + await this.loadFromDatabase(); + await this.migrateFromLocalStorage(); + await this.tryLoadSavedFiles(); + } + + async resolveMessageRoomId(messageId: string): Promise { + 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 { + return new Promise((resolve) => { + this.ngrxStore + .select(selectCurrentRoomName) + .pipe(take(1)) + .subscribe((name) => resolve(name || '')); + }); + } + + private async loadFromDatabase(): Promise { + try { + const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); + const grouped = new Map(); + + 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 { + 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 { + 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> { + const retainedSavedPaths = new Set(); + + 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; + } +} diff --git a/toju-app/src/app/domains/attachment/application/attachment-runtime.store.ts b/toju-app/src/app/domains/attachment/application/attachment-runtime.store.ts new file mode 100644 index 0000000..482b981 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/attachment-runtime.store.ts @@ -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(0); + + private attachmentsByMessage = new Map(); + private messageRoomIds = new Map(); + private originalFiles = new Map(); + private cancelledTransfers = new Set(); + private pendingRequests = new Map>(); + private chunkBuffers = new Map(); + private chunkCounts = new Map(); + + 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): 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): void { + this.pendingRequests.set(key, peers); + } + + getPendingRequestPeers(key: string): Set | 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); + } + } + } +} diff --git a/toju-app/src/app/domains/attachment/application/attachment-transfer-transport.service.ts b/toju-app/src/app/domains/attachment/application/attachment-transfer-transport.service.ts new file mode 100644 index 0000000..d7b0494 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/attachment-transfer-transport.service.ts @@ -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 { + 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 { + 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); + } +} diff --git a/toju-app/src/app/domains/attachment/application/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/attachment-transfer.service.ts new file mode 100644 index 0000000..724d5a5 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/attachment-transfer.service.ts @@ -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 { + const result: Record = {}; + + 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, + messageRoomIds?: Record + ): 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() + ); + + 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 { + 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 { + 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 { + 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(); + + 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); + } +} diff --git a/toju-app/src/app/domains/attachment/application/attachment.facade.ts b/toju-app/src/app/domains/attachment/application/attachment.facade.ts new file mode 100644 index 0000000..137527f --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/attachment.facade.ts @@ -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 + ): ReturnType { + return this.manager.getForMessage(...args); + } + + rememberMessageRoom( + ...args: Parameters + ): ReturnType { + return this.manager.rememberMessageRoom(...args); + } + + queueAutoDownloadsForMessage( + ...args: Parameters + ): ReturnType { + return this.manager.queueAutoDownloadsForMessage(...args); + } + + requestAutoDownloadsForRoom( + ...args: Parameters + ): ReturnType { + return this.manager.requestAutoDownloadsForRoom(...args); + } + + deleteForMessage( + ...args: Parameters + ): ReturnType { + return this.manager.deleteForMessage(...args); + } + + getAttachmentMetasForMessages( + ...args: Parameters + ): ReturnType { + return this.manager.getAttachmentMetasForMessages(...args); + } + + registerSyncedAttachments( + ...args: Parameters + ): ReturnType { + return this.manager.registerSyncedAttachments(...args); + } + + requestFromAnyPeer( + ...args: Parameters + ): ReturnType { + return this.manager.requestFromAnyPeer(...args); + } + + handleFileNotFound( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileNotFound(...args); + } + + requestImageFromAnyPeer( + ...args: Parameters + ): ReturnType { + return this.manager.requestImageFromAnyPeer(...args); + } + + requestFile( + ...args: Parameters + ): ReturnType { + return this.manager.requestFile(...args); + } + + publishAttachments( + ...args: Parameters + ): ReturnType { + return this.manager.publishAttachments(...args); + } + + handleFileAnnounce( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileAnnounce(...args); + } + + handleFileChunk( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileChunk(...args); + } + + handleFileRequest( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileRequest(...args); + } + + cancelRequest( + ...args: Parameters + ): ReturnType { + return this.manager.cancelRequest(...args); + } + + handleFileCancel( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileCancel(...args); + } + + fulfillRequestWithFile( + ...args: Parameters + ): ReturnType { + return this.manager.fulfillRequestWithFile(...args); + } +} diff --git a/toju-app/src/app/domains/attachment/domain/attachment-transfer.constants.ts b/toju-app/src/app/domains/attachment/domain/attachment-transfer.constants.ts new file mode 100644 index 0000000..fe8d0b9 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/attachment-transfer.constants.ts @@ -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.'; diff --git a/toju-app/src/app/domains/attachment/domain/attachment-transfer.models.ts b/toju-app/src/app/domains/attachment/domain/attachment-transfer.models.ts new file mode 100644 index 0000000..26a5a57 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/attachment-transfer.models.ts @@ -0,0 +1,57 @@ +import type { ChatEvent } from '../../../shared-kernel'; +import type { ChatAttachmentAnnouncement } from '../../../shared-kernel'; + +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; + +export interface FileChunkPayload { + messageId?: string; + fileId?: string; + fromPeerId?: string; + index?: number; + total?: number; + data?: ChatEvent['data']; +} + +export type FileRequestPayload = Pick; +export type FileCancelPayload = Pick; +export type FileNotFoundPayload = Pick; + +export type LocalFileWithPath = File & { + path?: string; +}; diff --git a/toju-app/src/app/domains/attachment/domain/attachment.constants.ts b/toju-app/src/app/domains/attachment/domain/attachment.constants.ts new file mode 100644 index 0000000..8edcc15 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/attachment.constants.ts @@ -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 diff --git a/toju-app/src/app/domains/attachment/domain/attachment.logic.ts b/toju-app/src/app/domains/attachment/domain/attachment.logic.ts new file mode 100644 index 0000000..1cad4d1 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/attachment.logic.ts @@ -0,0 +1,19 @@ +import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants'; +import type { Attachment } from './attachment.models'; + +export function isAttachmentMedia(attachment: Pick): 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): boolean { + return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES || + attachment.mime.startsWith('video/') || + attachment.mime.startsWith('audio/'); +} diff --git a/toju-app/src/app/domains/attachment/domain/attachment.models.ts b/toju-app/src/app/domains/attachment/domain/attachment.models.ts new file mode 100644 index 0000000..e838d90 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/attachment.models.ts @@ -0,0 +1,13 @@ +import type { ChatAttachmentMeta } from '../../../shared-kernel'; + +export type AttachmentMeta = ChatAttachmentMeta; + +export interface Attachment extends AttachmentMeta { + available: boolean; + objectUrl?: string; + receivedBytes?: number; + speedBps?: number; + startedAtMs?: number; + lastUpdateMs?: number; + requestError?: string; +} diff --git a/toju-app/src/app/domains/attachment/index.ts b/toju-app/src/app/domains/attachment/index.ts new file mode 100644 index 0000000..c96e739 --- /dev/null +++ b/toju-app/src/app/domains/attachment/index.ts @@ -0,0 +1,3 @@ +export * from './application/attachment.facade'; +export * from './domain/attachment.constants'; +export * from './domain/attachment.models'; diff --git a/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts new file mode 100644 index 0000000..ab1cd3c --- /dev/null +++ b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts @@ -0,0 +1,43 @@ +const ROOM_NAME_SANITIZER = /[^\w.-]+/g; +const STORED_FILENAME_SANITIZER = /[^\w.-]+/g; + +export function sanitizeAttachmentRoomName(roomName: string): string { + const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_'); + + return sanitizedRoomName || 'room'; +} + +export function resolveAttachmentStoredFilename(attachmentId: string, filename: string): string { + const sanitizedAttachmentId = attachmentId.trim().replace(STORED_FILENAME_SANITIZER, '_') || 'attachment'; + const basename = filename.trim().split(/[\\/]/) + .pop() ?? ''; + const extensionIndex = basename.lastIndexOf('.'); + + if (extensionIndex <= 0 || extensionIndex === basename.length - 1) { + return sanitizedAttachmentId; + } + + const sanitizedExtension = basename.slice(extensionIndex) + .replace(STORED_FILENAME_SANITIZER, '_') + .toLowerCase(); + + return sanitizedExtension === '.' + ? sanitizedAttachmentId + : `${sanitizedAttachmentId}${sanitizedExtension}`; +} + +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'; +} diff --git a/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.service.ts b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.service.ts new file mode 100644 index 0000000..f1c7218 --- /dev/null +++ b/toju-app/src/app/domains/attachment/infrastructure/attachment-storage.service.ts @@ -0,0 +1,131 @@ +import { Injectable, inject } from '@angular/core'; +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import type { Attachment } from '../domain/attachment.models'; +import { + resolveAttachmentStorageBucket, + resolveAttachmentStoredFilename, + sanitizeAttachmentRoomName +} from './attachment-storage.helpers'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentStorageService { + private readonly electronBridge = inject(ElectronBridgeService); + + async resolveExistingPath( + attachment: Pick + ): Promise { + return this.findExistingPath([attachment.filePath, attachment.savedPath]); + } + + async resolveLegacyImagePath(filename: string, roomName: string): Promise { + const appDataPath = await this.resolveAppDataPath(); + + if (!appDataPath) { + return null; + } + + return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]); + } + + async readFile(filePath: string): Promise { + const electronApi = this.electronBridge.getApi(); + + if (!electronApi || !filePath) { + return null; + } + + try { + return await electronApi.readFile(filePath); + } catch { + return null; + } + } + + async saveBlob( + attachment: Pick, + blob: Blob, + roomName: string + ): Promise { + 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}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`; + + await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer)); + + return diskPath; + } catch { + return null; + } + } + + async deleteFile(filePath: string): Promise { + const electronApi = this.electronBridge.getApi(); + + if (!electronApi || !filePath) { + return; + } + + try { + await electronApi.deleteFile(filePath); + } catch { /* best-effort cleanup */ } + } + + private async resolveAppDataPath(): Promise { + 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 { + 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); + } +} diff --git a/toju-app/src/app/domains/auth/README.md b/toju-app/src/app/domains/auth/README.md new file mode 100644 index 0000000..04d1708 --- /dev/null +++ b/toju-app/src/app/domains/auth/README.md @@ -0,0 +1,74 @@ +# Auth Domain + +Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components. + +## Module map + +``` +auth/ +├── application/ +│ └── auth.service.ts HTTP login/register against the active server endpoint +│ +├── feature/ +│ ├── login/ Login form component +│ ├── register/ Registration form component +│ └── user-bar/ Displays current user or login/register links +│ +└── index.ts Barrel exports +``` + +## Service overview + +`AuthService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store. + +```mermaid +graph TD + Login[LoginComponent] + Register[RegisterComponent] + UserBar[UserBarComponent] + Auth[AuthService] + SD[ServerDirectoryFacade] + Store[NgRx Store] + + Login --> Auth + Register --> Auth + UserBar --> Store + Auth --> SD + Login --> Store + + click Auth "application/auth.service.ts" "HTTP login/register" _blank + click Login "feature/login/" "Login form" _blank + click Register "feature/register/" "Registration form" _blank + click UserBar "feature/user-bar/" "Current user display" _blank + click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL" _blank +``` + +## Login flow + +```mermaid +sequenceDiagram + participant User + participant Login as LoginComponent + participant Auth as AuthService + participant SD as ServerDirectoryFacade + participant API as Server API + participant Store as NgRx Store + + User->>Login: Submit credentials + Login->>Auth: login(username, password) + Auth->>SD: getApiBaseUrl() + SD-->>Auth: https://server/api + Auth->>API: POST /api/auth/login + API-->>Auth: { userId, displayName } + Auth-->>Login: success + Login->>Store: UsersActions.setCurrentUser + Login->>Login: localStorage.setItem(currentUserId) +``` + +## Registration flow + +Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens. + +## User bar + +`UserBarComponent` reads the current user from the NgRx store. When logged in it shows the user's display name; when not logged in it shows links to the login and register views. diff --git a/src/app/core/services/auth.service.ts b/toju-app/src/app/domains/auth/application/auth.service.ts similarity index 92% rename from src/app/core/services/auth.service.ts rename to toju-app/src/app/domains/auth/application/auth.service.ts index 0fdface..3cbcc1d 100644 --- a/src/app/core/services/auth.service.ts +++ b/toju-app/src/app/domains/auth/application/auth.service.ts @@ -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. diff --git a/src/app/features/auth/login/login.component.html b/toju-app/src/app/domains/auth/feature/login/login.component.html similarity index 100% rename from src/app/features/auth/login/login.component.html rename to toju-app/src/app/domains/auth/feature/login/login.component.html diff --git a/src/app/features/auth/login/login.component.ts b/toju-app/src/app/domains/auth/feature/login/login.component.ts similarity index 87% rename from src/app/features/auth/login/login.component.ts rename to toju-app/src/app/domains/auth/feature/login/login.component.ts index f33dfb8..e7f2d87 100644 --- a/src/app/features/auth/login/login.component.ts +++ b/toju-app/src/app/domains/auth/feature/login/login.component.ts @@ -11,11 +11,11 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideLogIn } from '@ng-icons/lucide'; -import { AuthService } from '../../../core/services/auth.service'; -import { ServerDirectoryService } from '../../../core/services/server-directory.service'; -import { UsersActions } from '../../../store/users/users.actions'; -import { User } from '../../../core/models/index'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; +import { AuthService } from '../../application/auth.service'; +import { ServerDirectoryFacade } from '../../../server-directory'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { User } from '../../../../shared-kernel'; +import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; @Component({ selector: 'app-login', @@ -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 = ''; diff --git a/src/app/features/auth/register/register.component.html b/toju-app/src/app/domains/auth/feature/register/register.component.html similarity index 100% rename from src/app/features/auth/register/register.component.html rename to toju-app/src/app/domains/auth/feature/register/register.component.html diff --git a/src/app/features/auth/register/register.component.ts b/toju-app/src/app/domains/auth/feature/register/register.component.ts similarity index 87% rename from src/app/features/auth/register/register.component.ts rename to toju-app/src/app/domains/auth/feature/register/register.component.ts index cc48fd9..6b6a8f9 100644 --- a/src/app/features/auth/register/register.component.ts +++ b/toju-app/src/app/domains/auth/feature/register/register.component.ts @@ -11,11 +11,11 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideUserPlus } from '@ng-icons/lucide'; -import { AuthService } from '../../../core/services/auth.service'; -import { ServerDirectoryService } from '../../../core/services/server-directory.service'; -import { UsersActions } from '../../../store/users/users.actions'; -import { User } from '../../../core/models/index'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; +import { AuthService } from '../../application/auth.service'; +import { ServerDirectoryFacade } from '../../../server-directory'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { User } from '../../../../shared-kernel'; +import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; @Component({ selector: 'app-register', @@ -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 = ''; diff --git a/src/app/features/auth/user-bar/user-bar.component.html b/toju-app/src/app/domains/auth/feature/user-bar/user-bar.component.html similarity index 100% rename from src/app/features/auth/user-bar/user-bar.component.html rename to toju-app/src/app/domains/auth/feature/user-bar/user-bar.component.html diff --git a/src/app/features/auth/user-bar/user-bar.component.ts b/toju-app/src/app/domains/auth/feature/user-bar/user-bar.component.ts similarity index 92% rename from src/app/features/auth/user-bar/user-bar.component.ts rename to toju-app/src/app/domains/auth/feature/user-bar/user-bar.component.ts index ebe56a0..58f545b 100644 --- a/src/app/features/auth/user-bar/user-bar.component.ts +++ b/toju-app/src/app/domains/auth/feature/user-bar/user-bar.component.ts @@ -8,7 +8,7 @@ import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide'; -import { selectCurrentUser } from '../../../store/users/users.selectors'; +import { selectCurrentUser } from '../../../../store/users/users.selectors'; @Component({ selector: 'app-user-bar', diff --git a/toju-app/src/app/domains/auth/index.ts b/toju-app/src/app/domains/auth/index.ts new file mode 100644 index 0000000..b56289d --- /dev/null +++ b/toju-app/src/app/domains/auth/index.ts @@ -0,0 +1 @@ +export * from './application/auth.service'; diff --git a/toju-app/src/app/domains/chat/README.md b/toju-app/src/app/domains/chat/README.md new file mode 100644 index 0000000..1d1d413 --- /dev/null +++ b/toju-app/src/app/domains/chat/README.md @@ -0,0 +1,149 @@ +# Chat Domain + +Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync. + +## Module map + +``` +chat/ +├── application/ +│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server) +│ +├── domain/ +│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp +│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits +│ +├── feature/ +│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays) +│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop +│ │ ├── components/ +│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send +│ │ │ ├── message-item/ Single message bubble with edit/delete/react +│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting +│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview +│ │ ├── models/ View models for messages +│ │ └── services/ +│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering +│ │ +│ ├── klipy-gif-picker/ GIF search/browse picker panel +│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names) +│ └── user-list/ Online user sidebar +│ +└── index.ts Barrel exports +``` + +## Component composition + +`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF. + +```mermaid +graph TD + Chat[ChatMessagesComponent] + List[MessageListComponent] + Composer[MessageComposerComponent] + Overlays[MessageOverlays] + Item[MessageItemComponent] + GIF[KlipyGifPickerComponent] + Typing[TypingIndicatorComponent] + Users[UserListComponent] + + Chat --> List + Chat --> Composer + Chat --> Overlays + Chat --> GIF + List --> Item + Item --> Overlays + + click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view" _blank + click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank + click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank + click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank + click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank + click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank + click Typing "feature/typing-indicator/" "Typing indicator" _blank + click Users "feature/user-list/" "Online user sidebar" _blank +``` + +## Message lifecycle + +Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations. + +```mermaid +sequenceDiagram + participant User + participant Composer as MessageComposer + participant Store as NgRx Store + participant DC as Data Channel + participant Peer as Remote Peer + + User->>Composer: Type + send + Composer->>Store: dispatch addMessage + Composer->>DC: broadcastMessage(chat-message) + DC->>Peer: chat-message event + + Note over User: Edit + User->>Store: dispatch editMessage + User->>DC: broadcastMessage(edit-message) + + Note over User: Delete + User->>Store: dispatch deleteMessage (normaliseDeletedMessage) + User->>DC: broadcastMessage(delete-message) +``` + +## Text channel scoping + +`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing. + +If a room has no text channels, the room shell in `features/room/chat-room/` renders an empty state instead of mounting the chat view. The chat domain only mounts once a valid text channel exists. + +## Message sync + +When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200. + +```mermaid +sequenceDiagram + participant A as Peer A + participant B as Peer B + + A->>B: inventory (up to 1000 msg IDs + timestamps) + B->>B: findMissingIds(remote, local) + B->>A: request missing message IDs + A->>B: message payloads (chunked, 200/batch) +``` + +`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested. + +## GIF integration + +`KlipyService` checks availability on the active server, then proxies search requests through the server API. Rendered remote images now attempt a direct load first and only fall back to the image proxy after the browser reports a load failure, which is the practical approximation of a CORS or mixed-content fallback path in the renderer. + +```mermaid +graph LR + Picker[KlipyGifPickerComponent] + Klipy[KlipyService] + SD[ServerDirectoryFacade] + API[Server API] + + Picker --> Klipy + Klipy --> SD + Klipy --> API + + click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank + click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank + click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank +``` + +## Domain rules + +| Function | Purpose | +|---|---| +| `canEditMessage(msg, userId)` | Only the sender can edit their own message | +| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages | +| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` | +| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering | +| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer | +| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request | + +## Typing indicator + +`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing". diff --git a/src/app/core/services/klipy.service.ts b/toju-app/src/app/domains/chat/application/klipy.service.ts similarity index 95% rename from src/app/core/services/klipy.service.ts rename to toju-app/src/app/domains/chat/application/klipy.service.ts index 012ae98..e789d53 100644 --- a/src/app/core/services/klipy.service.ts +++ b/toju-app/src/app/domains/chat/application/klipy.service.ts @@ -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 @@ -135,6 +135,10 @@ export class KlipyService { } buildRenderableImageUrl(url: string): string { + return this.normalizeMediaUrl(url); + } + + buildImageProxyUrl(url: string): string { const trimmed = this.normalizeMediaUrl(url); if (!trimmed) diff --git a/toju-app/src/app/domains/chat/domain/message-sync.rules.ts b/toju-app/src/app/domains/chat/domain/message-sync.rules.ts new file mode 100644 index 0000000..e68bdbd --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/message-sync.rules.ts @@ -0,0 +1,59 @@ +/** Maximum number of recent messages to include in sync inventories. */ +export const INVENTORY_LIMIT = 1000; + +/** Number of messages per chunk for inventory / batch transfers. */ +export const CHUNK_SIZE = 200; + +/** Aggressive sync poll interval (10 seconds). */ +export const SYNC_POLL_FAST_MS = 10_000; + +/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */ +export const SYNC_POLL_SLOW_MS = 900_000; + +/** Sync timeout duration before auto-completing a cycle (5 seconds). */ +export const SYNC_TIMEOUT_MS = 5_000; + +/** Large limit used for legacy full-sync operations. */ +export const FULL_SYNC_LIMIT = 10_000; + +/** Inventory item representing a message's sync state. */ +export interface InventoryItem { + id: string; + ts: number; + rc: number; + ac?: number; +} + +/** Splits an array into chunks of the given size. */ +export function chunkArray(items: T[], size: number): T[][] { + const chunks: T[][] = []; + + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + + return chunks; +} + +/** Identifies missing or stale message IDs by comparing remote items against a local map. */ +export function findMissingIds( + remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[], + localMap: ReadonlyMap +): string[] { + const missing: string[] = []; + + for (const item of remoteItems) { + const local = localMap.get(item.id); + + if ( + !local || + item.ts > local.ts || + (item.rc !== undefined && item.rc !== local.rc) || + (item.ac !== undefined && item.ac !== local.ac) + ) { + missing.push(item.id); + } + } + + return missing; +} diff --git a/toju-app/src/app/domains/chat/domain/message.rules.ts b/toju-app/src/app/domains/chat/domain/message.rules.ts new file mode 100644 index 0000000..f85b811 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/message.rules.ts @@ -0,0 +1,31 @@ +import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel'; + +/** Extracts the effective timestamp from a message (editedAt takes priority). */ +export function getMessageTimestamp(msg: Message): number { + return msg.editedAt || msg.timestamp || 0; +} + +/** Computes the most recent timestamp across a batch of messages. */ +export function getLatestTimestamp(messages: Message[]): number { + return messages.reduce( + (max, msg) => Math.max(max, getMessageTimestamp(msg)), + 0 + ); +} + +/** Strips sensitive content from a deleted message. */ +export function normaliseDeletedMessage(message: Message): Message { + if (!message.isDeleted) + return message; + + return { + ...message, + content: DELETED_MESSAGE_CONTENT, + reactions: [] + }; +} + +/** Whether the given user is allowed to edit this message. */ +export function canEditMessage(message: Message, userId: string): boolean { + return message.senderId === userId; +} diff --git a/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts new file mode 100644 index 0000000..d73a818 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts @@ -0,0 +1,50 @@ +import { + Directive, + HostBinding, + HostListener, + effect, + inject, + input, + signal +} from '@angular/core'; +import { KlipyService } from '../application/klipy.service'; + +@Directive({ + selector: 'img[appChatImageProxyFallback]', + standalone: true +}) +export class ChatImageProxyFallbackDirective { + readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' }); + + private readonly klipy = inject(KlipyService); + private readonly renderedSource = signal(''); + private hasAppliedProxyFallback = false; + + constructor() { + effect(() => { + this.hasAppliedProxyFallback = false; + this.renderedSource.set(this.klipy.buildRenderableImageUrl(this.sourceUrl())); + }); + } + + @HostBinding('src') + get src(): string { + return this.renderedSource(); + } + + @HostListener('error') + handleError(): void { + if (this.hasAppliedProxyFallback) { + return; + } + + const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl()); + + if (!proxyUrl || proxyUrl === this.renderedSource()) { + return; + } + + this.hasAppliedProxyFallback = true; + this.renderedSource.set(proxyUrl); + } +} diff --git a/src/app/features/chat/chat-messages/chat-messages.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html similarity index 100% rename from src/app/features/chat/chat-messages/chat-messages.component.html rename to toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html diff --git a/src/app/features/chat/chat-messages/chat-messages.component.scss b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.scss similarity index 100% rename from src/app/features/chat/chat-messages/chat-messages.component.scss rename to toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.scss diff --git a/src/app/features/chat/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts similarity index 91% rename from src/app/features/chat/chat-messages/chat-messages.component.ts rename to toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index 7cf7bc9..cc9c4f0 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -8,18 +8,19 @@ 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 { MessagesActions } from '../../../store/messages/messages.actions'; +import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; +import { RealtimeSessionFacade } from '../../../../core/realtime'; +import { Attachment, AttachmentFacade } from '../../../attachment'; +import { KlipyGif } from '../../application/klipy.service'; +import { MessagesActions } from '../../../../store/messages/messages.actions'; import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing -} from '../../../store/messages/messages.selectors'; -import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; -import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; -import { Message } from '../../../core/models'; -import { WebRTCService } from '../../../core/services/webrtc.service'; +} from '../../../../store/messages/messages.selectors'; +import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors'; +import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; +import { Message } from '../../../../shared-kernel'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; @@ -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); @@ -106,8 +108,18 @@ export class ChatMessagesComponent { } handleTypingStarted(): void { + const roomId = this.currentRoom()?.id; + + if (!roomId) { + return; + } + try { - this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId }); + this.webrtc.sendRawMessage({ + type: 'typing', + serverId: roomId, + channelId: this.activeChannelId() ?? 'general' + }); } catch { /* ignore */ } @@ -252,17 +264,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) { diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html similarity index 96% rename from src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html rename to toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index 718e660..ce800e2 100644 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -132,7 +132,7 @@ (dragleave)="onDragLeave($event)" (drop)="onDrop($event)" > -
+
@if (klipy.isEnabled()) { + + + } @@ -113,7 +122,15 @@ }
@for (ch of voiceChannels(); track ch.id) { -
+
} @@ -242,13 +271,13 @@ In voice

} - @if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) { + @if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) { + @if (contextChannel()?.type === 'text') { + + } @if (canManageChannels()) {
- @if (!item().isLocal) { + @if (!item().isLocal && item().hasAudio) {
@@ -128,10 +134,10 @@ [class.tracking-[0.24em]]="!compact()" > - Live + {{ streamBadgeLabel() }}

@@ -156,7 +162,7 @@ /> - @if (!item().isLocal) { + @if (!item().isLocal && item().hasAudio) { + + + } @@ -46,6 +73,14 @@ (closed)="closeMenu()" [width]="'w-44'" > + +