10 Commits

Author SHA1 Message Date
Myx
44588e8789 feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
2026-04-18 21:27:04 +02:00
Myx
167c45ba8d test: Add 8 people voice tests 2026-04-18 14:24:11 +02:00
Myx
bd21568726 feat: Add user metadata changing display name and description with sync
All checks were successful
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Successful in 5m2s
Queue Release Build / build-windows (push) Successful in 16m44s
Queue Release Build / build-linux (push) Successful in 27m12s
Queue Release Build / finalize (push) Successful in 22s
2026-04-17 22:55:50 +02:00
Myx
3ba8a2c9eb fix: Fix corrupt database, Add soundcloud and spotify embeds 2026-04-17 19:44:26 +02:00
Myx
28797a0141 perf: use lookup for chats
Some checks failed
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 15m8s
Queue Release Build / build-linux (push) Successful in 26m49s
Queue Release Build / build-windows (push) Failing after 12m6s
Queue Release Build / finalize (push) Has been skipped
2026-04-17 03:53:53 +02:00
Myx
17738ec484 feat: Add profile images 2026-04-17 03:06:44 +02:00
Myx
35b616fb77 refactor: Clean lint errors and organise files 2026-04-17 01:06:01 +02:00
Myx
2927a86fbb feat: Add user statuses and cards 2026-04-16 22:52:45 +02:00
Myx
b4ac0cdc92 fix: Windows audio mute fix 2026-04-16 19:07:44 +02:00
Myx
f3b56fb1cc fix: Db corruption fix
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 10m0s
Queue Release Build / build-linux (push) Successful in 25m59s
Queue Release Build / build-windows (push) Successful in 21m44s
Queue Release Build / finalize (push) Successful in 18s
2026-04-13 02:23:09 +02:00
200 changed files with 11644 additions and 1494 deletions

5
.gitignore vendored
View File

@@ -60,3 +60,8 @@ dist-server/*
AGENTS.md AGENTS.md
doc/** doc/**
metoyou.sqlite*
metoyou.sqlite
vitest/

165
README.md
View File

@@ -1,119 +1,88 @@
<img src="./images/icon.png" width="100" height="100"> <img src="./images/icon.png" width="100" height="100">
# MetoYou / Toju
# Toju / Zoracord MetoYou is a desktop-first chat stack managed as an npm monorepo. The repository contains the Angular 21 product client, the Electron desktop shell, the Node/TypeScript signaling server, the Playwright E2E suite, and the Angular 19 marketing website.
Desktop chat app with four parts: ## Packages
- `src/` Angular client | Path | Purpose | Docs |
- `electron/` desktop shell, IPC, and local database | --- | --- | --- |
- `server/` directory server, join request API, and websocket events | `toju-app/` | Angular 21 product client | [toju-app/README.md](toju-app/README.md) |
- `website/` Toju website served at toju.app | `electron/` | Electron main process, preload bridge, IPC, and desktop integrations | [electron/README.md](electron/README.md) |
| `server/` | Signaling server, server-directory API, and websocket runtime | [server/README.md](server/README.md) |
| `e2e/` | Playwright end-to-end coverage for the product client | [e2e/README.md](e2e/README.md) |
| `website/` | Angular 19 marketing site served separately from the product client | [website/README.md](website/README.md) |
## Install ## Install
1. Run `npm install` 1. Run `npm install` from the repository root.
2. Run `cd server && npm install` 2. Run `cd server && npm install` for the server package.
3. Copy `.env.example` to `.env` 3. If you need to work on the marketing site, run `cd website && npm install`.
4. Copy `.env.example` to `.env`.
## Config ## Configuration
Root `.env`: - Root `.env` controls local SSL with `SSL=true|false`.
- The server also honors an optional `PORT` environment override at runtime.
- When `SSL=true`, run `./generate-cert.sh` once or let `./dev.sh` generate local certificates on first launch.
- `server/data/variables.json` stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. The server normalizes this file on startup.
- When `serverProtocol` is `https`, the certificates in `.certs/` must exist and match the configured host or IP.
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode ## Main Commands
- `PORT=3001` changes the server port in local development and overrides the server app setting
If `SSL=true`, run `./generate-cert.sh` once. - `npm run dev` starts the full desktop stack: server, product client, and Electron.
- `npm run start` starts only the Angular product client in `toju-app/`.
- `npm run electron:dev` starts the Angular product client and Electron together.
- `npm run server:dev` starts only the server with reload.
- `npm run build` builds the Angular product client to `dist/client`.
- `npm run build:electron` builds the Electron code to `dist/electron`.
- `npm run build:all` builds the product client, Electron, and server.
- `npm run test` runs the product-client Vitest suite.
- `npm run lint` runs ESLint across the repo.
- `npm run lint:fix` formats Angular templates, sorts template properties, and applies ESLint fixes.
- `npm run test:e2e`, `npm run test:e2e:ui`, `npm run test:e2e:debug`, and `npm run test:e2e:report` run the Playwright suite and report tooling.
Server files: ## Repository Map
- `server/data/variables.json` holds `klipyApiKey`
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
## Main commands
- `npm run dev` starts Angular, the server, and Electron
- `npm run electron:dev` starts Angular and Electron
- `npm run server:dev` starts only the server
- `npm run build` builds the Angular client
- `npm run build:electron` builds the Electron code
- `npm run build:all` builds client, Electron, and server
- `npm run lint` runs ESLint
- `npm run lint:fix` formats templates, sorts template props, and fixes lint issues
- `npm run test` runs Angular tests
## Server project
The code in `server/` is a small Node and TypeScript service.
It handles the public server directory, join requests, websocket updates, and Klipy routes.
Inside `server/`:
- `npm run dev` starts the server with reload
- `npm run build` compiles to `dist/`
- `npm run start` runs the compiled server
# Images
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
## Main Toju app Structure
| Path | Description | | Path | Description |
|------|-------------| | --- | --- |
| `src/app/` | Main application root | | `toju-app/src/app/domains/` | Product-client bounded contexts and domain facades |
| `src/app/core/` | Core utilities, services, models | | `toju-app/src/app/infrastructure/` | Shared client-side technical runtime such as persistence and realtime |
| `src/app/domains/` | Domain-driven modules | | `toju-app/src/app/shared-kernel/` | Cross-domain contracts shared inside the product client |
| `src/app/features/` | UI feature modules | | `electron/` | Electron bootstrap, preload surface, IPC handlers, CQRS, and desktop adapters |
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) | | `server/src/` | Express app, websocket runtime, config, CQRS, and persistence layers |
| `src/app/shared/` | Shared UI components | | `e2e/` | Playwright tests, helpers, fixtures, and page objects |
| `src/app/shared-kernel/` | Shared domain contracts & models | | `website/src/` | Marketing-site pages, assets, and SSR entry points |
| `src/app/store/` | Global state management | | `tools/` | Build, release, formatting, and packaging scripts |
| `src/assets/` | Static assets |
| `src/environments/` | Environment configs |
--- ## Product Client Docs
### Domains | Area | Docs |
| --- | --- |
| Domains index | [toju-app/src/app/domains/README.md](toju-app/src/app/domains/README.md) |
| Access Control | [toju-app/src/app/domains/access-control/README.md](toju-app/src/app/domains/access-control/README.md) |
| Attachment | [toju-app/src/app/domains/attachment/README.md](toju-app/src/app/domains/attachment/README.md) |
| Authentication | [toju-app/src/app/domains/authentication/README.md](toju-app/src/app/domains/authentication/README.md) |
| Chat | [toju-app/src/app/domains/chat/README.md](toju-app/src/app/domains/chat/README.md) |
| Notifications | [toju-app/src/app/domains/notifications/README.md](toju-app/src/app/domains/notifications/README.md) |
| Profile Avatar | [toju-app/src/app/domains/profile-avatar/README.md](toju-app/src/app/domains/profile-avatar/README.md) |
| Screen Share | [toju-app/src/app/domains/screen-share/README.md](toju-app/src/app/domains/screen-share/README.md) |
| Server Directory | [toju-app/src/app/domains/server-directory/README.md](toju-app/src/app/domains/server-directory/README.md) |
| Theme | [toju-app/src/app/domains/theme/README.md](toju-app/src/app/domains/theme/README.md) |
| Voice Connection | [toju-app/src/app/domains/voice-connection/README.md](toju-app/src/app/domains/voice-connection/README.md) |
| Voice Session | [toju-app/src/app/domains/voice-session/README.md](toju-app/src/app/domains/voice-session/README.md) |
| Persistence | [toju-app/src/app/infrastructure/persistence/README.md](toju-app/src/app/infrastructure/persistence/README.md) |
| Realtime | [toju-app/src/app/infrastructure/realtime/README.md](toju-app/src/app/infrastructure/realtime/README.md) |
| Shared Kernel | [toju-app/src/app/shared-kernel/README.md](toju-app/src/app/shared-kernel/README.md) |
| Path | Link | ## Supporting Docs
|------|------|
| 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) |
--- - [doc/monorepo.md](doc/monorepo.md)
- [doc/typescript.md](doc/typescript.md)
- [docs/architecture.md](docs/architecture.md)
### Infrastructure ## Screenshots
| Path | Link | <img src="./website/src/images/screenshots/gif.png" width="700" height="400">
|------|------| <img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
| 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) |

36
e2e/README.md Normal file
View File

@@ -0,0 +1,36 @@
# End-to-End Tests
Playwright suite for the MetoYou / Toju product client. The tests exercise browser flows such as authentication, chat, voice, screen sharing, and settings with reusable page objects and helpers.
## Commands
Run these from the repository root:
- `npm run test:e2e` runs the full Playwright suite.
- `npm run test:e2e:ui` opens Playwright UI mode.
- `npm run test:e2e:debug` runs the suite in debug mode.
- `npm run test:e2e:report` opens the HTML report in `test-results/html-report`.
You can also run `npx playwright test` from `e2e/` directly.
## Runtime
- `playwright.config.ts` starts `cd ../toju-app && npx ng serve` as the test web server.
- The suite targets `http://localhost:4200`.
- Tests currently run with a single Chromium worker.
- The browser launches with fake media-device flags and grants microphone/camera permissions.
- Artifacts are written to `../test-results/artifacts`, and the HTML report is written to `../test-results/html-report`.
## Structure
| Path | Description |
| --- | --- |
| `tests/` | Test specs grouped by feature area such as `auth/`, `chat/`, `voice/`, `screen-share/`, and `settings/` |
| `pages/` | Reusable Playwright page objects |
| `helpers/` | Test helpers, fake-server utilities, and WebRTC helpers |
| `fixtures/` | Shared test fixtures |
## Notes
- The suite is product-client focused; it does not currently spin up the marketing website.
- Keep reusable browser flows in `pages/` and cross-test utilities in `helpers/`.

View File

@@ -5,23 +5,15 @@ import {
type BrowserContext, type BrowserContext,
type Browser type Browser
} from '@playwright/test'; } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { join } from 'node:path'; import { join } from 'node:path';
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint'; import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
import { startTestServer, type TestServerHandle } from '../helpers/test-server';
export interface Client { export interface Client {
page: Page; page: Page;
context: BrowserContext; context: BrowserContext;
} }
interface TestServerHandle {
port: number;
url: string;
stop: () => Promise<void>;
}
interface MultiClientFixture { interface MultiClientFixture {
createClient: () => Promise<Client>; createClient: () => Promise<Client>;
testServer: TestServerHandle; testServer: TestServerHandle;
@@ -31,10 +23,9 @@ const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav');
const CHROMIUM_FAKE_MEDIA_ARGS = [ const CHROMIUM_FAKE_MEDIA_ARGS = [
'--use-fake-device-for-media-stream', '--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream', '--use-fake-ui-for-media-stream',
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}` `--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`,
'--autoplay-policy=no-user-gesture-required'
]; ];
const E2E_DIR = join(__dirname, '..');
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export const test = base.extend<MultiClientFixture>({ export const test = base.extend<MultiClientFixture>({
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => { testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
@@ -81,122 +72,3 @@ export const test = base.extend<MultiClientFixture>({
}); });
export { expect } from '@playwright/test'; export { expect } from '@playwright/test';
async function startTestServer(retries = 3): Promise<TestServerHandle> {
for (let attempt = 1; attempt <= retries; attempt++) {
const port = await allocatePort();
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_PORT: String(port)
},
stdio: 'pipe'
});
child.stdout?.on('data', (chunk: Buffer | string) => {
process.stdout.write(chunk.toString());
});
child.stderr?.on('data', (chunk: Buffer | string) => {
process.stderr.write(chunk.toString());
});
try {
await waitForServerReady(port, child);
} catch (error) {
await stopServer(child);
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
continue;
}
throw error;
}
return {
port,
url: `http://localhost:${port}`,
stop: async () => {
await stopServer(child);
}
};
}
throw new Error('startTestServer: unreachable');
}
async function allocatePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
if (!address || typeof address === 'string') {
probe.close();
reject(new Error('Failed to resolve an ephemeral test server port'));
return;
}
const { port } = address;
probe.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
}
try {
const response = await fetch(readyUrl);
if (response.ok) {
return;
}
} catch {
// Server still starting.
}
await wait(250);
}
throw new Error(`Timed out waiting for test server on port ${port}`);
}
async function stopServer(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) {
return;
}
child.kill('SIGTERM');
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
if (!exited && child.exitCode === null) {
child.kill('SIGKILL');
await once(child, 'exit');
}
}
function wait(durationMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}

View File

@@ -3,7 +3,16 @@ import { type BrowserContext, type Page } from '@playwright/test';
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys'; const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
type SeededEndpointStorageState = { export interface SeededEndpointInput {
id: string;
name: string;
url: string;
isActive?: boolean;
isDefault?: boolean;
status?: string;
}
interface SeededEndpointStorageState {
key: string; key: string;
removedKey: string; removedKey: string;
endpoints: { endpoints: {
@@ -14,24 +23,35 @@ type SeededEndpointStorageState = {
isDefault: boolean; isDefault: boolean;
status: string; status: string;
}[]; }[];
}; }
function buildSeededEndpointStorageState( function buildSeededEndpointStorageState(
port: number = Number(process.env.TEST_SERVER_PORT) || 3099 endpointsOrPort: readonly SeededEndpointInput[] | number = Number(process.env.TEST_SERVER_PORT) || 3099
): SeededEndpointStorageState { ): SeededEndpointStorageState {
const endpoint = { const endpoints = Array.isArray(endpointsOrPort)
? endpointsOrPort.map((endpoint) => ({
id: endpoint.id,
name: endpoint.name,
url: endpoint.url,
isActive: endpoint.isActive ?? true,
isDefault: endpoint.isDefault ?? false,
status: endpoint.status ?? 'unknown'
}))
: [
{
id: 'e2e-test-server', id: 'e2e-test-server',
name: 'E2E Test Server', name: 'E2E Test Server',
url: `http://localhost:${port}`, url: `http://localhost:${endpointsOrPort}`,
isActive: true, isActive: true,
isDefault: false, isDefault: false,
status: 'unknown' status: 'unknown'
}; }
];
return { return {
key: SERVER_ENDPOINTS_STORAGE_KEY, key: SERVER_ENDPOINTS_STORAGE_KEY,
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY, removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
endpoints: [endpoint] endpoints
}; };
} }
@@ -40,7 +60,11 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
const storage = window.localStorage; const storage = window.localStorage;
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints)); storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden'])); storage.setItem(storageState.removedKey, JSON.stringify([
'default',
'toju-primary',
'toju-sweden'
]));
} catch { } catch {
// about:blank and some Playwright UI pages deny localStorage access. // about:blank and some Playwright UI pages deny localStorage access.
} }
@@ -55,11 +79,20 @@ export async function installTestServerEndpoint(
await context.addInitScript(applySeededEndpointStorageState, storageState); await context.addInitScript(applySeededEndpointStorageState, storageState);
} }
export async function installTestServerEndpoints(
context: BrowserContext,
endpoints: readonly SeededEndpointInput[]
): Promise<void> {
const storageState = buildSeededEndpointStorageState(endpoints);
await context.addInitScript(applySeededEndpointStorageState, storageState);
}
/** /**
* Seed localStorage with a single signal endpoint pointing at the test server. * Seed localStorage with a single signal endpoint pointing at the test server.
* Must be called AFTER navigating to the app origin (localStorage is per-origin) * Must be called AFTER navigating to the app origin (localStorage is per-origin)
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is * but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
* relied upon calling it in the first goto() landing page is fine since the * relied upon - calling it in the first goto() landing page is fine since the
* page will re-read on next navigation/reload). * page will re-read on next navigation/reload).
* *
* Typical usage: * Typical usage:
@@ -75,3 +108,12 @@ export async function seedTestServerEndpoint(
await page.evaluate(applySeededEndpointStorageState, storageState); await page.evaluate(applySeededEndpointStorageState, storageState);
} }
export async function seedTestServerEndpoints(
page: Page,
endpoints: readonly SeededEndpointInput[]
): Promise<void> {
const storageState = buildSeededEndpointStorageState(endpoints);
await page.evaluate(applySeededEndpointStorageState, storageState);
}

View File

@@ -16,6 +16,7 @@ const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
const SERVER_DIR = join(__dirname, '..', '..', 'server'); const SERVER_DIR = join(__dirname, '..', '..', 'server');
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts'); const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json'); const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
// ── Create isolated temp data directory ────────────────────────────── // ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-')); const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
@@ -43,8 +44,8 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
// Module resolution (require/import) uses __dirname, so server source // Module resolution (require/import) uses __dirname, so server source
// and node_modules are found from the real server/ directory. // and node_modules are found from the real server/ directory.
const child = spawn( const child = spawn(
'npx', process.execPath,
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY], [TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{ {
cwd: tmpDir, cwd: tmpDir,
env: { env: {
@@ -55,7 +56,6 @@ const child = spawn(
DB_SYNCHRONIZE: 'true', DB_SYNCHRONIZE: 'true',
}, },
stdio: 'inherit', stdio: 'inherit',
shell: true,
} }
); );

132
e2e/helpers/test-server.ts Normal file
View File

@@ -0,0 +1,132 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { join } from 'node:path';
export interface TestServerHandle {
port: number;
url: string;
stop: () => Promise<void>;
}
const E2E_DIR = join(__dirname, '..');
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export async function startTestServer(retries = 3): Promise<TestServerHandle> {
for (let attempt = 1; attempt <= retries; attempt++) {
const port = await allocatePort();
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_PORT: String(port)
},
stdio: 'pipe'
});
child.stdout?.on('data', (chunk: Buffer | string) => {
process.stdout.write(chunk.toString());
});
child.stderr?.on('data', (chunk: Buffer | string) => {
process.stderr.write(chunk.toString());
});
try {
await waitForServerReady(port, child);
} catch (error) {
await stopServer(child);
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
continue;
}
throw error;
}
return {
port,
url: `http://localhost:${port}`,
stop: async () => {
await stopServer(child);
}
};
}
throw new Error('startTestServer: unreachable');
}
async function allocatePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
if (!address || typeof address === 'string') {
probe.close();
reject(new Error('Failed to resolve an ephemeral test server port'));
return;
}
const { port } = address;
probe.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
}
try {
const response = await fetch(readyUrl);
if (response.ok) {
return;
}
} catch {
// Server still starting.
}
await wait(250);
}
throw new Error(`Timed out waiting for test server on port ${port}`);
}
async function stopServer(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) {
return;
}
child.kill('SIGTERM');
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
if (!exited && child.exitCode === null) {
child.kill('SIGKILL');
await once(child, 'exit');
}
}
function wait(durationMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}

View File

@@ -11,9 +11,15 @@ import { type Page } from '@playwright/test';
export async function installWebRTCTracking(page: Page): Promise<void> { export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
const connections: RTCPeerConnection[] = []; const connections: RTCPeerConnection[] = [];
const syntheticMediaResources: {
audioCtx: AudioContext;
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}[] = [];
(window as any).__rtcConnections = connections; (window as any).__rtcConnections = connections;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; (window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
const OriginalRTCPeerConnection = window.RTCPeerConnection; const OriginalRTCPeerConnection = window.RTCPeerConnection;
@@ -40,48 +46,6 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; (window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection); Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
// Patch getUserMedia to use an AudioContext oscillator for audio
// instead of the hardware capture device. Chromium's fake audio
// device intermittently fails to produce frames after renegotiation.
const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
const wantsAudio = !!constraints?.audio;
if (!wantsAudio) {
return origGetUserMedia(constraints);
}
// Get the original stream (may include video)
const originalStream = await origGetUserMedia(constraints);
const audioCtx = new AudioContext();
const oscillator = audioCtx.createOscillator();
oscillator.frequency.value = 440;
const dest = audioCtx.createMediaStreamDestination();
oscillator.connect(dest);
oscillator.start();
const synthAudioTrack = dest.stream.getAudioTracks()[0];
const resultStream = new MediaStream();
resultStream.addTrack(synthAudioTrack);
// Keep any video tracks from the original stream
for (const videoTrack of originalStream.getVideoTracks()) {
resultStream.addTrack(videoTrack);
}
// Stop original audio tracks since we're not using them
for (const track of originalStream.getAudioTracks()) {
track.stop();
}
return resultStream;
};
// Patch getDisplayMedia to return a synthetic screen share stream // Patch getDisplayMedia to return a synthetic screen share stream
// (canvas-based video + 880Hz oscillator audio) so the browser // (canvas-based video + 880Hz oscillator audio) so the browser
// picker dialog is never shown. // picker dialog is never shown.
@@ -128,10 +92,32 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
osc.connect(dest); osc.connect(dest);
osc.start(); osc.start();
if (audioCtx.state === 'suspended') {
try {
await audioCtx.resume();
} catch {}
}
const audioTrack = dest.stream.getAudioTracks()[0]; const audioTrack = dest.stream.getAudioTracks()[0];
// Combine video + audio into one stream // Combine video + audio into one stream
const resultStream = new MediaStream([videoTrack, audioTrack]); const resultStream = new MediaStream([videoTrack, audioTrack]);
syntheticMediaResources.push({
audioCtx,
source: osc,
drawIntervalId: drawInterval as unknown as number
});
audioTrack.addEventListener('ended', () => {
clearInterval(drawInterval);
try {
osc.stop();
} catch {}
void audioCtx.close().catch(() => {});
}, { once: true });
// Tag the stream so tests can identify it // Tag the stream so tests can identify it
(resultStream as any).__isScreenShare = true; (resultStream as any).__isScreenShare = true;
@@ -143,6 +129,48 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
/** /**
* Wait until at least one RTCPeerConnection reaches the 'connected' state. * Wait until at least one RTCPeerConnection reaches the 'connected' state.
*/ */
/**
* Ensure every `AudioContext` created by the page auto-resumes so that
* the input-gain Web Audio pipeline (`source -> gain -> destination`) never
* stalls in the "suspended" state.
*
* On Linux with multiple headless Chromium instances, `new AudioContext()`
* can start suspended without a user-gesture gate, causing the media
* pipeline to emit only a single RTP packet.
*
* Call once per page, BEFORE navigating, alongside `installWebRTCTracking`.
*/
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
await page.addInitScript(() => {
const OrigAudioContext = window.AudioContext;
(window as any).AudioContext = function(this: AudioContext, ...args: any[]) {
const ctx: AudioContext = new OrigAudioContext(...args);
// Track all created AudioContexts for test diagnostics
const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[];
tracked.push(ctx);
if (ctx.state === 'suspended') {
ctx.resume().catch(() => { /* noop */ });
}
// Also catch transitions to suspended after creation
ctx.addEventListener('statechange', () => {
if (ctx.state === 'suspended') {
ctx.resume().catch(() => { /* noop */ });
}
});
return ctx;
} as any;
(window as any).AudioContext.prototype = OrigAudioContext.prototype;
Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext);
});
}
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> { export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
() => (window as any).__rtcConnections?.some( () => (window as any).__rtcConnections?.some(
@@ -163,6 +191,177 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
); );
} }
/** Returns the number of tracked peer connections in `connected` state. */
export async function getConnectedPeerCount(page: Page): Promise<number> {
return page.evaluate(
() => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length ?? 0
);
}
/** Wait until the expected number of peer connections are `connected`. */
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction(
(count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length === count,
expectedCount,
{ timeout }
);
}
/**
* Resume all suspended AudioContext instances created by the synthetic
* media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so
* Chrome treats the call as a user-gesture - this satisfies the autoplay
* policy that otherwise blocks `AudioContext.resume()`.
*/
export async function resumeSyntheticAudioContexts(page: Page): Promise<number> {
const cdpSession = await page.context().newCDPSession(page);
try {
const result = await cdpSession.send('Runtime.evaluate', {
expression: `(async () => {
const resources = window.__rtcSyntheticMediaResources;
if (!resources) return 0;
let resumed = 0;
for (const r of resources) {
if (r.audioCtx.state === 'suspended') {
await r.audioCtx.resume();
resumed++;
}
}
return resumed;
})()`,
awaitPromise: true,
userGesture: true
});
return result.result.value ?? 0;
} finally {
await cdpSession.detach();
}
}
interface PerPeerAudioStat {
connectionState: string;
inboundBytes: number;
inboundPackets: number;
outboundBytes: number;
outboundPackets: number;
}
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) {
return [];
}
const snapshots: PerPeerAudioStat[] = [];
for (const pc of connections) {
let inboundBytes = 0;
let inboundPackets = 0;
let outboundBytes = 0;
let outboundPackets = 0;
try {
const stats = await pc.getStats();
stats.forEach((report: any) => {
const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') {
outboundBytes += report.bytesSent ?? 0;
outboundPackets += report.packetsSent ?? 0;
}
if (report.type === 'inbound-rtp' && kind === 'audio') {
inboundBytes += report.bytesReceived ?? 0;
inboundPackets += report.packetsReceived ?? 0;
}
});
} catch {
// Closed connection.
}
snapshots.push({
connectionState: pc.connectionState,
inboundBytes,
inboundPackets,
outboundBytes,
outboundPackets
});
}
return snapshots;
});
}
/** Wait until every connected peer connection shows inbound and outbound audio flow. */
export async function waitForAllPeerAudioFlow(
page: Page,
expectedConnectedPeers: number,
timeoutMs = 45_000,
pollIntervalMs = 1_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
// Track which peer indices have been confirmed flowing at least once.
// This prevents a peer from being missed just because it briefly paused
// during one specific poll interval.
const confirmedFlowing = new Set<number>();
let previous = await getPerPeerAudioStats(page);
while (Date.now() < deadline) {
await page.waitForTimeout(pollIntervalMs);
const current = await getPerPeerAudioStats(page);
const connectedPeers = current.filter((stat) => stat.connectionState === 'connected');
if (connectedPeers.length >= expectedConnectedPeers) {
for (let index = 0; index < current.length; index++) {
const curr = current[index];
if (!curr || curr.connectionState !== 'connected') {
continue;
}
const prev = previous[index] ?? {
connectionState: 'new',
inboundBytes: 0,
inboundPackets: 0,
outboundBytes: 0,
outboundPackets: 0
};
const inboundFlowing = curr.inboundBytes > prev.inboundBytes || curr.inboundPackets > prev.inboundPackets;
const outboundFlowing = curr.outboundBytes > prev.outboundBytes || curr.outboundPackets > prev.outboundPackets;
if (inboundFlowing && outboundFlowing) {
confirmedFlowing.add(index);
}
}
// Check if enough peers have been confirmed across all samples
const connectedIndices = current
.map((stat, idx) => stat.connectionState === 'connected' ? idx : -1)
.filter((idx) => idx >= 0);
const confirmedCount = connectedIndices.filter((idx) => confirmedFlowing.has(idx)).length;
if (confirmedCount >= expectedConnectedPeers) {
return;
}
}
previous = current;
}
throw new Error(`Timed out waiting for ${expectedConnectedPeers} peers with bidirectional audio flow`);
}
/** /**
* Get outbound and inbound audio RTP stats aggregated across all peer * Get outbound and inbound audio RTP stats aggregated across all peer
* connections. Uses a per-connection high water mark stored on `window` so * connections. Uses a per-connection high water mark stored on `window` so

View File

@@ -4,11 +4,11 @@ import {
type Page type Page
} from '@playwright/test'; } from '@playwright/test';
export type ChatDropFilePayload = { export interface ChatDropFilePayload {
name: string; name: string;
mimeType: string; mimeType: string;
base64: string; base64: string;
}; }
export class ChatMessagesPage { export class ChatMessagesPage {
readonly composer: Locator; readonly composer: Locator;
@@ -115,7 +115,8 @@ export class ChatMessagesPage {
getEmbedCardByTitle(title: string): Locator { getEmbedCardByTitle(title: string): Locator {
return this.page.locator('app-chat-link-embed').filter({ return this.page.locator('app-chat-link-embed').filter({
has: this.page.getByText(title, { exact: true }) has: this.page.getByText(title, { exact: true })
}).last(); })
.last();
} }
async editOwnMessage(originalText: string, updatedText: string): Promise<void> { async editOwnMessage(originalText: string, updatedText: string): Promise<void> {

View File

@@ -19,13 +19,65 @@ export class ChatRoomPage {
/** Click a voice channel by name in the channels sidebar to join voice. */ /** Click a voice channel by name in the channels sidebar to join voice. */
async joinVoiceChannel(channelName: string) { async joinVoiceChannel(channelName: string) {
const channelButton = this.page.locator('app-rooms-side-panel') const channelButton = this.getVoiceChannelButton(channelName);
.getByRole('button', { name: channelName, exact: true });
if (await channelButton.count() === 0) {
await this.refreshRoomMetadata();
}
if (await channelButton.count() === 0) {
// Second attempt - metadata might still be syncing
await this.page.waitForTimeout(2_000);
await this.refreshRoomMetadata();
}
await expect(channelButton).toBeVisible({ timeout: 15_000 }); await expect(channelButton).toBeVisible({ timeout: 15_000 });
await channelButton.click(); await channelButton.click();
} }
/** Creates a voice channel if it is not already present in the current room. */
async ensureVoiceChannelExists(channelName: string) {
const channelButton = this.getVoiceChannelButton(channelName);
if (await channelButton.count() > 0) {
return;
}
await this.refreshRoomMetadata();
// Wait a bit longer for Angular to render the channel list after refresh
try {
await expect(channelButton).toBeVisible({ timeout: 5_000 });
return;
} catch {
// Channel genuinely doesn't exist - create it
}
await this.openCreateVoiceChannelDialog();
try {
await this.createChannel(channelName);
} catch {
// If the dialog didn't close (e.g. duplicate name validation), dismiss it
const dialog = this.page.locator('app-confirm-dialog');
if (await dialog.isVisible()) {
const cancelButton = dialog.getByRole('button', { name: 'Cancel' });
const closeButton = dialog.getByRole('button', { name: 'Close dialog' });
if (await cancelButton.isVisible()) {
await cancelButton.click();
} else if (await closeButton.isVisible()) {
await closeButton.click();
}
await expect(dialog).not.toBeVisible({ timeout: 5_000 }).catch(() => {});
}
}
await expect(channelButton).toBeVisible({ timeout: 15_000 });
}
/** Click a text channel by name in the channels sidebar to switch chat rooms. */ /** Click a text channel by name in the channels sidebar to switch chat rooms. */
async joinTextChannel(channelName: string) { async joinTextChannel(channelName: string) {
const channelButton = this.getTextChannelButton(channelName); const channelButton = this.getTextChannelButton(channelName);
@@ -100,6 +152,11 @@ export class ChatRoomPage {
return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first(); return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first();
} }
/** Get the deafen toggle button inside voice controls. */
get deafenButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucideHeadphones"])').first();
}
/** Get the disconnect/hang-up button (destructive styled). */ /** Get the disconnect/hang-up button (destructive styled). */
get disconnectButton() { get disconnectButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first(); return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
@@ -112,10 +169,9 @@ export class ChatRoomPage {
/** Get the count of voice users listed under a voice channel. */ /** Get the count of voice users listed under a voice channel. */
async getVoiceUserCountInChannel(channelName: string): Promise<number> { async getVoiceUserCountInChannel(channelName: string): Promise<number> {
const channelSection = this.page.locator('app-rooms-side-panel') // The voice channel button is inside a wrapper div; user avatars are siblings within that wrapper
.getByRole('button', { name: channelName }) const channelWrapper = this.getVoiceChannelButton(channelName).locator('xpath=ancestor::div[1]');
.locator('..'); const userAvatars = channelWrapper.locator('app-user-avatar');
const userAvatars = channelSection.locator('app-user-avatar');
return userAvatars.count(); return userAvatars.count();
} }
@@ -154,9 +210,11 @@ export class ChatRoomPage {
} }
private getTextChannelButton(channelName: string): Locator { private getTextChannelButton(channelName: string): Locator {
const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i'); return this.channelsSidePanel.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first();
}
return this.channelsSidePanel.getByRole('button', { name: channelPattern }).first(); private getVoiceChannelButton(channelName: string): Locator {
return this.channelsSidePanel.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`).first();
} }
private async createTextChannelThroughComponent(channelName: string): Promise<void> { private async createTextChannelThroughComponent(channelName: string): Promise<void> {
@@ -384,7 +442,3 @@ export class ChatRoomPage {
await this.page.waitForTimeout(500); await this.page.waitForTimeout(500);
} }
} }
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -1,4 +1,8 @@
import { expect, type Page, type Locator } from '@playwright/test'; import {
expect,
type Page,
type Locator
} from '@playwright/test';
export class RegisterPage { export class RegisterPage {
readonly usernameInput: Locator; readonly usernameInput: Locator;
@@ -25,11 +29,12 @@ export class RegisterPage {
try { try {
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 }); await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
} catch { } catch {
// Angular router may redirect to /login on first load; click through. // Angular router may redirect to /login on first load; use the
const registerLink = this.page.getByRole('link', { name: 'Register' }) // visible login-form action instead of broad text matching.
.or(this.page.getByText('Register')); const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last();
await registerLink.first().click(); await expect(registerButton).toBeVisible({ timeout: 10_000 });
await registerButton.click();
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 }); await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
} }

View File

@@ -7,6 +7,8 @@ import {
export class ServerSearchPage { export class ServerSearchPage {
readonly searchInput: Locator; readonly searchInput: Locator;
readonly createServerButton: Locator; readonly createServerButton: Locator;
readonly railCreateServerButton: Locator;
readonly searchCreateServerButton: Locator;
readonly settingsButton: Locator; readonly settingsButton: Locator;
// Create server dialog // Create server dialog
@@ -21,7 +23,9 @@ export class ServerSearchPage {
constructor(private page: Page) { constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers...'); this.searchInput = page.getByPlaceholder('Search servers...');
this.createServerButton = page.getByRole('button', { name: 'Create New Server' }); this.railCreateServerButton = page.locator('button[title="Create Server"]');
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
this.createServerButton = this.searchCreateServerButton;
this.settingsButton = page.locator('button[title="Settings"]'); this.settingsButton = page.locator('button[title="Settings"]');
// Create dialog elements // Create dialog elements
@@ -39,8 +43,20 @@ export class ServerSearchPage {
await this.page.goto('/search'); await this.page.goto('/search');
} }
async createServer(name: string, options?: { description?: string; topic?: string }) { async createServer(name: string, options?: { description?: string; topic?: string; sourceId?: string }) {
await this.createServerButton.click(); if (!await this.serverNameInput.isVisible()) {
if (await this.searchCreateServerButton.isVisible()) {
await this.searchCreateServerButton.click();
} else {
await this.railCreateServerButton.click();
if (!await this.serverNameInput.isVisible()) {
await expect(this.searchCreateServerButton).toBeVisible({ timeout: 10_000 });
await this.searchCreateServerButton.click();
}
}
}
await expect(this.serverNameInput).toBeVisible(); await expect(this.serverNameInput).toBeVisible();
await this.serverNameInput.fill(name); await this.serverNameInput.fill(name);
@@ -52,6 +68,10 @@ export class ServerSearchPage {
await this.serverTopicInput.fill(options.topic); await this.serverTopicInput.fill(options.topic);
} }
if (options?.sourceId) {
await this.signalEndpointSelect.selectOption(options.sourceId);
}
await this.dialogCreateButton.click(); await this.dialogCreateButton.click();
} }

View File

@@ -6,14 +6,14 @@ export default defineConfig({
expect: { timeout: 10_000 }, expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: 1, workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']], reporter: [['html', { outputFolder: '../test-results/html-report', open: 'never' }], ['list']],
outputDir: '../test-results/artifacts', outputDir: '../test-results/artifacts',
use: { use: {
baseURL: 'http://localhost:4200', baseURL: 'http://localhost:4200',
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
video: 'on-first-retry', video: 'on-first-retry',
actionTimeout: 15_000, actionTimeout: 15_000
}, },
projects: [ projects: [
{ {
@@ -25,15 +25,16 @@ export default defineConfig({
args: [ args: [
'--use-fake-device-for-media-stream', '--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream', '--use-fake-ui-for-media-stream',
], '--autoplay-policy=no-user-gesture-required'
}, ]
}, }
}, }
}
], ],
webServer: { webServer: {
command: 'cd ../toju-app && npx ng serve', command: 'cd ../toju-app && npx ng serve',
port: 4200, url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000
}, }
}); });

View File

@@ -1,12 +1,13 @@
import { type Page } from '@playwright/test'; import { type Page } from '@playwright/test';
import { test, expect, type Client } from '../../fixtures/multi-client'; import {
test,
expect,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page'; import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page'; import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page'; import { ChatRoomPage } from '../../pages/chat-room.page';
import { import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
ChatMessagesPage,
type ChatDropFilePayload
} from '../../pages/chat-messages.page';
const MOCK_EMBED_URL = 'https://example.test/mock-embed'; const MOCK_EMBED_URL = 'https://example.test/mock-embed';
const MOCK_EMBED_TITLE = 'Mock Embed Title'; const MOCK_EMBED_TITLE = 'Mock Embed Title';
@@ -133,14 +134,14 @@ test.describe('Chat messaging features', () => {
}); });
}); });
type ChatScenario = { interface ChatScenario {
alice: Client; alice: Client;
bob: Client; bob: Client;
aliceRoom: ChatRoomPage; aliceRoom: ChatRoomPage;
bobRoom: ChatRoomPage; bobRoom: ChatRoomPage;
aliceMessages: ChatMessagesPage; aliceMessages: ChatMessagesPage;
bobMessages: ChatMessagesPage; bobMessages: ChatMessagesPage;
}; }
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> { async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
const suffix = uniqueName('chat'); const suffix = uniqueName('chat');
@@ -170,6 +171,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
aliceCredentials.displayName, aliceCredentials.displayName,
aliceCredentials.password aliceCredentials.password
); );
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 }); await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
await bobRegisterPage.goto(); await bobRegisterPage.goto();
@@ -178,6 +180,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
bobCredentials.displayName, bobCredentials.displayName,
bobCredentials.password bobCredentials.password
); );
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
const aliceSearchPage = new ServerSearchPage(alice.page); const aliceSearchPage = new ServerSearchPage(alice.page);
@@ -185,6 +188,7 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
await aliceSearchPage.createServer(serverName, { await aliceSearchPage.createServer(serverName, {
description: 'E2E chat server for messaging feature coverage' description: 'E2E chat server for messaging feature coverage'
}); });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearchPage = new ServerSearchPage(bob.page); const bobSearchPage = new ServerSearchPage(bob.page);
@@ -259,6 +263,7 @@ async function installChatFeatureMocks(page: Page): Promise<void> {
siteName: 'Mock Docs' siteName: 'Mock Docs'
}) })
}); });
return; return;
} }
@@ -291,5 +296,6 @@ function buildMockSvgMarkup(label: string): string {
} }
function uniqueName(prefix: string): string { function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
} }

View File

@@ -0,0 +1,714 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
chromium,
type BrowserContext,
type Page
} from '@playwright/test';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
interface TestUser {
displayName: string;
password: string;
username: string;
}
interface AvatarUploadPayload {
buffer: Buffer;
dataUrl: string;
mimeType: string;
name: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
user: TestUser;
userDataDir: string;
}
interface ProfileMetadata {
description?: string;
displayName: string;
}
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([
0x21,
0xF9,
0x04
]);
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
0x21,
0xFF,
0x0B,
0x4E,
0x45,
0x54,
0x53,
0x43,
0x41,
0x50,
0x45,
0x32,
0x2E,
0x30,
0x03,
0x01,
0x00,
0x00,
0x00
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const VOICE_CHANNEL = 'General';
test.describe('Profile avatar sync', () => {
test.describe.configure({ timeout: 240_000 });
test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => {
const suffix = uniqueName('avatar');
const serverName = `Avatar Sync Server ${suffix}`;
const messageText = `Avatar sync message ${suffix}`;
const avatarA = buildAnimatedGifUpload('alpha');
const avatarB = buildAnimatedGifUpload('beta');
const aliceUser: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobUser: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const carolUser: TestUser = {
username: `carol_${suffix}`,
displayName: 'Carol',
password: 'TestPass123!'
};
const clients: PersistentClient[] = [];
try {
const alice = await createPersistentClient(aliceUser, testServer.port);
const bob = await createPersistentClient(bobUser, testServer.port);
clients.push(alice, bob);
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
await registerUser(alice);
await registerUser(bob);
const aliceSearchPage = new ServerSearchPage(alice.page);
await aliceSearchPage.createServer(serverName, {
description: 'Avatar synchronization E2E coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await joinServerFromSearch(bob.page, serverName);
await waitForRoomReady(alice.page);
await waitForRoomReady(bob.page);
await waitForConnectedPeerCount(alice.page, 1);
await waitForConnectedPeerCount(bob.page, 1);
await expectUserRowVisible(bob.page, aliceUser.displayName);
});
const roomUrl = alice.page.url();
await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice sees the updated avatar in voice controls', async () => {
await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL);
await joinVoiceChannel(alice.page, VOICE_CHANNEL);
await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl);
});
const carol = await createPersistentClient(carolUser, testServer.port);
clients.push(carol);
await test.step('Carol joins after the first change and sees the updated avatar', async () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await waitForConnectedPeerCount(alice.page, 2);
await waitForConnectedPeerCount(carol.page, 1);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice avatar is used in chat messages for everyone in the room', async () => {
const aliceMessagesPage = new ChatMessagesPage(alice.page);
await aliceMessagesPage.sendMessage(messageText);
await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl);
});
await test.step('Alice changes the avatar again and all three users see the update in real time', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl);
});
await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => {
await restartPersistentClient(bob, testServer.port);
await openRoomAfterRestart(bob, roomUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await restartPersistentClient(carol, testServer.port);
await openRoomAfterRestart(carol, roomUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await restartPersistentClient(alice, testServer.port);
await openRoomAfterRestart(alice, roomUrl);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
});
} finally {
await Promise.all(clients.map(async (client) => {
await closePersistentClient(client);
await rm(client.userDataDir, { recursive: true, force: true });
}));
}
});
});
test.describe('Profile metadata sync', () => {
test.describe.configure({ timeout: 240_000 });
test('syncs display name and description changes for online and late-joining users and persists after restart', async ({ testServer }) => {
const suffix = uniqueName('profile');
const serverName = `Profile Sync Server ${suffix}`;
const messageText = `Profile sync message ${suffix}`;
const firstProfile: ProfileMetadata = {
displayName: `Alice One ${suffix}`,
description: `First synced profile description ${suffix}`
};
const secondProfile: ProfileMetadata = {
displayName: `Alice Two ${suffix}`,
description: `Second synced profile description ${suffix}`
};
const aliceUser: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobUser: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const carolUser: TestUser = {
username: `carol_${suffix}`,
displayName: 'Carol',
password: 'TestPass123!'
};
const clients: PersistentClient[] = [];
try {
const alice = await createPersistentClient(aliceUser, testServer.port);
const bob = await createPersistentClient(bobUser, testServer.port);
clients.push(alice, bob);
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
await registerUser(alice);
await registerUser(bob);
const aliceSearchPage = new ServerSearchPage(alice.page);
await aliceSearchPage.createServer(serverName, {
description: 'Profile synchronization E2E coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await joinServerFromSearch(bob.page, serverName);
await waitForRoomReady(alice.page);
await waitForRoomReady(bob.page);
await waitForConnectedPeerCount(alice.page, 1);
await waitForConnectedPeerCount(bob.page, 1);
await expectUserRowVisible(bob.page, aliceUser.displayName);
});
const roomUrl = alice.page.url();
await test.step('Alice updates her profile while Bob is online and Bob sees it live', async () => {
await updateProfileFromRoomSidebar(alice.page, {
displayName: aliceUser.displayName
}, firstProfile);
await expectUserRowVisible(alice.page, firstProfile.displayName);
await expectUserRowVisible(bob.page, firstProfile.displayName);
await expectProfileCardDetails(bob.page, firstProfile);
});
const carol = await createPersistentClient(carolUser, testServer.port);
clients.push(carol);
await test.step('Carol joins after the first change and sees the updated profile', async () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await waitForConnectedPeerCount(alice.page, 2);
await waitForConnectedPeerCount(carol.page, 1);
await expectUserRowVisible(carol.page, firstProfile.displayName);
await expectProfileCardDetails(carol.page, firstProfile);
});
await test.step('Alice changes her profile again and new chat messages use the latest display name', async () => {
await updateProfileFromRoomSidebar(alice.page, firstProfile, secondProfile);
await expectUserRowVisible(alice.page, secondProfile.displayName);
await expectUserRowVisible(bob.page, secondProfile.displayName);
await expectUserRowVisible(carol.page, secondProfile.displayName);
await expectProfileCardDetails(bob.page, secondProfile);
await expectProfileCardDetails(carol.page, secondProfile);
const aliceMessagesPage = new ChatMessagesPage(alice.page);
await aliceMessagesPage.sendMessage(messageText);
await expectChatMessageSenderName(alice.page, messageText, secondProfile.displayName);
await expectChatMessageSenderName(bob.page, messageText, secondProfile.displayName);
await expectChatMessageSenderName(carol.page, messageText, secondProfile.displayName);
});
await test.step('Bob, Carol, and Alice keep the latest profile after a full app restart', async () => {
await restartPersistentClient(bob, testServer.port);
await openRoomAfterRestart(bob, roomUrl);
await expectUserRowVisible(bob.page, secondProfile.displayName);
await expectProfileCardDetails(bob.page, secondProfile);
await restartPersistentClient(carol, testServer.port);
await openRoomAfterRestart(carol, roomUrl);
await expectUserRowVisible(carol.page, secondProfile.displayName);
await expectProfileCardDetails(carol.page, secondProfile);
await restartPersistentClient(alice, testServer.port);
await openRoomAfterRestart(alice, roomUrl);
await expectUserRowVisible(alice.page, secondProfile.displayName);
await expectProfileCardDetails(alice.page, secondProfile);
});
} finally {
await Promise.all(clients.map(async (client) => {
await closePersistentClient(client);
await rm(client.userDataDir, { recursive: true, force: true });
}));
}
});
});
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
const session = await launchPersistentSession(userDataDir, testServerPort);
return {
context: session.context,
page: session.page,
user,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await closePersistentClient(client);
const session = await launchPersistentSession(client.userDataDir, testServerPort);
client.context = session.context;
client.page = session.page;
}
async function closePersistentClient(client: PersistentClient): Promise<void> {
try {
await client.context.close();
} catch {
// Ignore repeated cleanup attempts during finally.
}
}
async function launchPersistentSession(
userDataDir: string,
testServerPort: number
): Promise<{ context: BrowserContext; page: Page }> {
const context = await chromium.launchPersistentContext(userDataDir, {
args: CLIENT_LAUNCH_ARGS,
baseURL: 'http://localhost:4200',
permissions: ['microphone', 'camera']
});
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? await context.newPage();
await installWebRTCTracking(page);
return { context, page };
}
async function registerUser(client: PersistentClient): Promise<void> {
const registerPage = new RegisterPage(client.page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
const serverCard = page.locator('button', { hasText: serverName }).first();
await searchPage.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
async function ensureVoiceChannelExists(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true });
if (await existingVoiceChannel.count() > 0) {
return;
}
await chatRoom.openCreateVoiceChannelDialog();
await chatRoom.createChannel(channelName);
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}
async function joinVoiceChannel(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
await chatRoom.joinVoiceChannel(channelName);
await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
}
async function uploadAvatarFromRoomSidebar(
page: Page,
displayName: string,
avatar: AvatarUploadPayload
): Promise<void> {
const currentUserRow = getUserRow(page, displayName);
const profileFileInput = page.locator('app-profile-card input[type="file"]');
const applyButton = page.getByRole('button', { name: 'Apply picture' });
await expect(currentUserRow).toBeVisible({ timeout: 15_000 });
if (await profileFileInput.count() === 0) {
await currentUserRow.click();
await expect(profileFileInput).toBeAttached({ timeout: 10_000 });
}
await profileFileInput.setInputFiles({
name: avatar.name,
mimeType: avatar.mimeType,
buffer: avatar.buffer
});
await expect(applyButton).toBeVisible({ timeout: 10_000 });
await applyButton.click();
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
}
async function updateProfileFromRoomSidebar(
page: Page,
currentProfile: ProfileMetadata,
nextProfile: ProfileMetadata
): Promise<void> {
const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName);
const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true });
await expect(displayNameButton).toBeVisible({ timeout: 10_000 });
await displayNameButton.click();
const displayNameInput = profileCard.locator('input[type="text"]').first();
await expect(displayNameInput).toBeVisible({ timeout: 10_000 });
await displayNameInput.fill(nextProfile.displayName);
await displayNameInput.blur();
await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 });
const currentDescriptionText = currentProfile.description || 'Add a description';
await profileCard.getByText(currentDescriptionText, { exact: true }).click();
const descriptionInput = profileCard.locator('textarea').first();
await expect(descriptionInput).toBeVisible({ timeout: 10_000 });
await descriptionInput.fill(nextProfile.description || '');
await descriptionInput.blur();
await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 });
await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });
if (nextProfile.description) {
await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 });
}
}
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
if (client.page.url().includes('/login')) {
const loginPage = new LoginPage(client.page);
await loginPage.login(client.user.username, client.user.password);
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
}
await waitForRoomReady(client.page);
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await navigate();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
if (!isTransientNavigationError || attempt === attempts) {
throw error;
}
}
}
throw lastError instanceof Error
? lastError
: new Error(`Navigation failed after ${attempts} attempts`);
}
async function waitForRoomReady(page: Page): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
await messagesPage.waitForReady();
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
await page.waitForFunction((expectedCount) => {
const connections = (window as {
__rtcConnections?: RTCPeerConnection[];
}).__rtcConnections ?? [];
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
}, count, { timeout });
}
async function openProfileCardFromUserRow(page: Page, displayName: string) {
await closeProfileCard(page);
const row = getUserRow(page, displayName);
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
const profileCard = page.locator('app-profile-card');
await expect(profileCard).toBeVisible({ timeout: 10_000 });
return profileCard;
}
async function closeProfileCard(page: Page): Promise<void> {
const profileCard = page.locator('app-profile-card');
if (await profileCard.count() === 0) {
return;
}
try {
await expect(profileCard).toBeVisible({ timeout: 1_000 });
} catch {
return;
}
await page.mouse.click(8, 8);
await expect(profileCard).toHaveCount(0, { timeout: 10_000 });
}
function getUserRow(page: Page, displayName: string) {
const usersSidePanel = page.locator('app-rooms-side-panel').last();
return usersSidePanel.locator('[role="button"]').filter({
has: page.getByText(displayName, { exact: true })
})
.first();
}
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
}
async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise<void> {
const profileCard = await openProfileCardFromUserRow(page, profile.displayName);
await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 });
if (profile.description) {
await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 });
}
await closeProfileCard(page);
}
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
const row = getUserRow(page, displayName);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: `${displayName} avatar src should update`
}).toBe(expectedDataUrl);
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return false;
}
return image.evaluate((element) => {
const img = element as HTMLImageElement;
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
});
}, {
timeout: 20_000,
message: `${displayName} avatar image should load`
}).toBe(true);
}
async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
const messageItem = messagesPage.getMessageItemByText(messageText);
await expect(messageItem).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = messageItem.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: `Chat message avatar for "${messageText}" should update`
}).toBe(expectedDataUrl);
}
async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
const messageItem = messagesPage.getMessageItemByText(messageText);
await expect(messageItem).toBeVisible({ timeout: 20_000 });
await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 });
}
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
const voiceControls = page.locator('app-voice-controls');
await expect(voiceControls).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = voiceControls.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: 'Voice controls avatar should update'
}).toBe(expectedDataUrl);
}
function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
if (frameStart < 0) {
throw new Error('Failed to locate GIF frame marker for animated avatar payload');
}
const header = baseGif.subarray(0, frameStart);
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([
Buffer.from([
0x21,
0xFE,
commentData.length
]),
commentData,
Buffer.from([0x00])
]);
const buffer = Buffer.concat([
header,
NETSCAPE_LOOP_EXTENSION,
commentExtension,
frame,
frame,
Buffer.from([0x3B])
]);
const base64 = buffer.toString('base64');
return {
buffer,
dataUrl: `data:image/gif;base64,${base64}`,
mimeType: 'image/gif',
name: `animated-avatar-${label}.gif`
};
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -8,7 +8,8 @@ import {
waitForVideoFlow, waitForVideoFlow,
waitForOutboundVideoFlow, waitForOutboundVideoFlow,
waitForInboundVideoFlow, waitForInboundVideoFlow,
dumpRtcDiagnostics dumpRtcDiagnostics,
installAutoResumeAudioContext
} from '../../helpers/webrtc-helpers'; } from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page'; import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page'; import { ServerSearchPage } from '../../pages/server-search.page';
@@ -38,7 +39,7 @@ async function registerUser(page: import('@playwright/test').Page, user: typeof
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
} }
/** Both users register Alice creates server Bob joins. */ /** Both users register -> Alice creates server -> Bob joins. */
async function setupServerWithBothUsers( async function setupServerWithBothUsers(
alice: { page: import('@playwright/test').Page }, alice: { page: import('@playwright/test').Page },
bob: { page: import('@playwright/test').Page } bob: { page: import('@playwright/test').Page }
@@ -80,11 +81,11 @@ async function joinVoiceTogether(
await expect(existingChannel).toBeVisible({ timeout: 10_000 }); await expect(existingChannel).toBeVisible({ timeout: 10_000 });
} }
const bobRoom = new ChatRoomPage(bob.page);
const doJoin = async () => {
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL); await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
const bobRoom = new ChatRoomPage(bob.page);
await bobRoom.joinVoiceChannel(VOICE_CHANNEL); await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
@@ -93,6 +94,32 @@ async function joinVoiceTogether(
await waitForPeerConnected(bob.page, 30_000); await waitForPeerConnected(bob.page, 30_000);
await waitForAudioStatsPresent(alice.page, 20_000); await waitForAudioStatsPresent(alice.page, 20_000);
await waitForAudioStatsPresent(bob.page, 20_000); await waitForAudioStatsPresent(bob.page, 20_000);
};
await doJoin();
// Chromium's --use-fake-device-for-media-stream can produce a silent
// capture track on the very first getUserMedia call. If bidirectional
// audio doesn't flow within a short window, leave and rejoin voice to
// re-acquire the mic (second getUserMedia on a warm device works).
const aliceDelta = await waitForAudioFlow(alice.page, 10_000);
const bobDelta = await waitForAudioFlow(bob.page, 10_000);
const aliceFlowing =
(aliceDelta.outboundBytesDelta > 0 || aliceDelta.outboundPacketsDelta > 0) &&
(aliceDelta.inboundBytesDelta > 0 || aliceDelta.inboundPacketsDelta > 0);
const bobFlowing =
(bobDelta.outboundBytesDelta > 0 || bobDelta.outboundPacketsDelta > 0) &&
(bobDelta.inboundBytesDelta > 0 || bobDelta.inboundPacketsDelta > 0);
if (!aliceFlowing || !bobFlowing) {
// Leave voice
await aliceRoom.disconnectButton.click();
await bobRoom.disconnectButton.click();
await alice.page.waitForTimeout(2_000);
// Rejoin
await doJoin();
}
// Expand voice workspace on both clients so the demand-driven screen // Expand voice workspace on both clients so the demand-driven screen
// share request flow can fire (requires connectRemoteShares = true). // share request flow can fire (requires connectRemoteShares = true).
@@ -142,6 +169,20 @@ test.describe('Screen sharing', () => {
await installWebRTCTracking(alice.page); await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page); await installWebRTCTracking(bob.page);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
// Seed deterministic voice settings so noise reduction doesn't
// swallow the fake audio tone.
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
alice.page.on('console', msg => console.log('[Alice]', msg.text())); alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text()));
@@ -251,6 +292,18 @@ test.describe('Screen sharing', () => {
await installWebRTCTracking(alice.page); await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page); await installWebRTCTracking(bob.page);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
alice.page.on('console', msg => console.log('[Alice]', msg.text())); alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text()));
@@ -323,6 +376,18 @@ test.describe('Screen sharing', () => {
await installWebRTCTracking(alice.page); await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page); await installWebRTCTracking(bob.page);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
alice.page.on('console', msg => console.log('[Alice]', msg.text())); alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text()));

View File

@@ -0,0 +1,227 @@
import { test, expect } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import {
installAutoResumeAudioContext,
installWebRTCTracking,
waitForConnectedPeerCount
} from '../../helpers/webrtc-helpers';
const VOICE_SETTINGS = JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
});
/**
* Seed deterministic voice settings on a page so noise reduction and
* input gating don't interfere with the fake audio tone.
*/
async function seedVoiceSettings(page: import('@playwright/test').Page): Promise<void> {
await page.addInitScript((settings: string) => {
localStorage.setItem('metoyou_voice_settings', settings);
}, VOICE_SETTINGS);
}
/**
* Close all of a client's RTCPeerConnections and prevent any
* reconnection by sabotaging the SDP negotiation methods on the
* prototype - new connections get created but can never complete ICE.
*
* Chromium doesn't fire `connectionstatechange` on programmatic
* `close()`, so we dispatch the event manually so the app's recovery
* code runs and updates the connected-peers signal.
*/
async function killAndBlockPeerConnections(page: import('@playwright/test').Page): Promise<void> {
await page.evaluate(() => {
// Sabotage SDP methods so no NEW connections can negotiate.
const proto = RTCPeerConnection.prototype;
proto.createOffer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
proto.createAnswer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
proto.setLocalDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
proto.setRemoteDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError'));
// Close every existing connection and manually fire the event
// Chromium omits when close() is called from JS.
const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
for (const pc of connections) {
try {
pc.close();
pc.dispatchEvent(new Event('connectionstatechange'));
} catch { /* already closed */ }
}
});
}
test.describe('Connectivity warning', () => {
test.describe.configure({ timeout: 180_000 });
test('shows warning icon when a peer loses all connections', async ({ createClient }) => {
const suffix = `connwarn_${Date.now()}`;
const serverName = `ConnWarn ${suffix}`;
const alice = await createClient();
const bob = await createClient();
const charlie = await createClient();
// ── Install WebRTC tracking & AudioContext auto-resume ──
for (const client of [
alice,
bob,
charlie
]) {
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
await seedVoiceSettings(client.page);
}
// ── Register all three users ──
await test.step('Register Alice', async () => {
const register = new RegisterPage(alice.page);
await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Bob', async () => {
const register = new RegisterPage(bob.page);
await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Charlie', async () => {
const register = new RegisterPage(charlie.page);
await register.goto();
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
});
// ── Create server and have everyone join ──
await test.step('Alice creates a server', async () => {
const search = new ServerSearchPage(alice.page);
await search.createServer(serverName);
});
await test.step('Bob joins the server', async () => {
const search = new ServerSearchPage(bob.page);
await search.searchInput.fill(serverName);
const card = bob.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
await test.step('Charlie joins the server', async () => {
const search = new ServerSearchPage(charlie.page);
await search.searchInput.fill(serverName);
const card = charlie.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
const charlieRoom = new ChatRoomPage(charlie.page);
// ── Everyone joins voice ──
await test.step('All three join voice', async () => {
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
await charlieRoom.joinVoiceChannel('General');
});
await test.step('All users see each other in voice', async () => {
// Each user should see the other two in the voice channel list.
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
aliceRoom.channelsSidePanel.getByText('Charlie')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Charlie')
).toBeVisible({ timeout: 20_000 });
await expect(
charlieRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await expect(
charlieRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
});
// ── Wait for full mesh to establish ──
await test.step('All peer connections establish', async () => {
// Each client should have 2 connected peers (full mesh of 3).
await waitForConnectedPeerCount(alice.page, 2, 30_000);
await waitForConnectedPeerCount(bob.page, 2, 30_000);
await waitForConnectedPeerCount(charlie.page, 2, 30_000);
});
// ── Break Charlie's connections ──
await test.step('Kill Charlie peer connections and block reconnection', async () => {
await killAndBlockPeerConnections(charlie.page);
// Give the health service time to detect the desync.
// Peer latency pings stop -> connectedPeers updates -> desyncPeerIds recalculates.
await alice.page.waitForTimeout(15_000);
});
// ── Assert connectivity warnings ──
//
// The warning icon (lucideAlertTriangle) is a direct sibling of the
// user-name span inside the same voice-row div. Using the CSS
// general-sibling combinator (~) avoids accidentally matching a
// parent container that holds multiple rows.
await test.step('Alice sees warning icon next to Charlie', async () => {
const charlieWarning = aliceRoom.channelsSidePanel
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
});
await test.step('Bob sees warning icon next to Charlie', async () => {
const charlieWarning = bobRoom.channelsSidePanel
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
});
await test.step('Alice does NOT see warning icon next to Bob', async () => {
const bobWarning = aliceRoom.channelsSidePanel
.locator('span.truncate:has-text("Bob") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(bobWarning).not.toBeVisible();
});
await test.step('Charlie sees local desync banner', async () => {
const desyncBanner = charlie.page.locator('text=You may have connectivity issues');
await expect(desyncBanner).toBeVisible({ timeout: 30_000 });
});
});
});

View File

@@ -0,0 +1,126 @@
import { test, expect } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
test.describe('ICE server settings', () => {
test.describe.configure({ timeout: 120_000 });
async function registerAndOpenNetworkSettings(page: import('@playwright/test').Page, suffix: string) {
const register = new RegisterPage(page);
await register.goto();
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
await page.getByTitle('Settings').click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
}
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = `ice_${Date.now()}`;
await test.step('Register and open Network settings', async () => {
await registerAndOpenNetworkSettings(page, suffix);
});
const iceSection = page.getByTestId('ice-server-settings');
await test.step('Default STUN servers are listed', async () => {
await expect(iceSection).toBeVisible({ timeout: 5_000 });
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
await expect(entries.first()).toBeVisible({ timeout: 5_000 });
const count = await entries.count();
expect(count).toBeGreaterThanOrEqual(1);
});
await test.step('Add a STUN server', async () => {
await page.getByTestId('ice-type-select').selectOption('stun');
await page.getByTestId('ice-url-input').fill('stun:custom.example.com:3478');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('stun:custom.example.com:3478')).toBeVisible({ timeout: 5_000 });
});
await test.step('Add a TURN server with credentials', async () => {
await page.getByTestId('ice-type-select').selectOption('turn');
await page.getByTestId('ice-url-input').fill('turn:relay.example.com:443');
await page.getByTestId('ice-username-input').fill('testuser');
await page.getByTestId('ice-credential-input').fill('testpass');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('turn:relay.example.com:443')).toBeVisible({ timeout: 5_000 });
await expect(page.getByText('User: testuser')).toBeVisible({ timeout: 5_000 });
});
await test.step('Remove first entry and verify count decreases', async () => {
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
const countBefore = await entries.count();
await entries.first().getByTitle('Remove')
.click();
await expect(entries).toHaveCount(countBefore - 1, { timeout: 5_000 });
});
await test.step('Reorder: move second entry up', async () => {
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
const count = await entries.count();
if (count >= 2) {
const secondText = await entries.nth(1).locator('p')
.first()
.textContent();
if (!secondText) {
throw new Error('Expected ICE server entry text before reordering');
}
await entries.nth(1).getByTitle('Move up (higher priority)')
.click();
// Wait for the moved entry text to appear at position 0
await expect(entries.first().locator('p')
.first()).toHaveText(secondText, { timeout: 5_000 });
}
});
await test.step('Restore defaults resets list', async () => {
await page.getByTestId('ice-restore-defaults').click();
await expect(page.getByText('turn:relay.example.com:443')).not.toBeVisible({ timeout: 3_000 });
const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]');
await expect(entries.first()).toBeVisible({ timeout: 5_000 });
});
await test.step('Settings persist after page reload', async () => {
await page.getByTestId('ice-type-select').selectOption('stun');
await page.getByTestId('ice-url-input').fill('stun:persist-test.example.com:3478');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByTitle('Settings').click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
});
});
test('validates TURN entries require credentials', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = `iceval_${Date.now()}`;
await test.step('Register and open Network settings', async () => {
await registerAndOpenNetworkSettings(page, suffix);
});
await test.step('Adding TURN without credentials shows error', async () => {
await page.getByTestId('ice-type-select').selectOption('turn');
await page.getByTestId('ice-url-input').fill('turn:noncred.example.com:443');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('Username is required for TURN servers')).toBeVisible({ timeout: 5_000 });
});
});
});

View File

@@ -0,0 +1,216 @@
import { test, expect } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import {
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForPeerConnected,
waitForConnectedPeerCount,
waitForAudioStatsPresent
} from '../../helpers/webrtc-helpers';
const ICE_STORAGE_KEY = 'metoyou_ice_servers';
interface StoredIceServerEntry {
type?: string;
urls?: string;
}
/**
* Tests that user-configured ICE servers are persisted and used by peer connections.
*
* On localhost TURN relay is never needed (direct always succeeds), so this test:
* 1. Seeds Bob's browser with an additional TURN entry via localStorage.
* 2. Has both users join voice with differing ICE configs.
* 3. Verifies both can connect and Bob's TURN entry is still in storage.
*/
test.describe('STUN/TURN fallback behaviour', () => {
test.describe.configure({ timeout: 180_000 });
test('users with different ICE configs can voice chat together', async ({ createClient }) => {
const suffix = `turnfb_${Date.now()}`;
const serverName = `Fallback ${suffix}`;
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking before any navigation so we can inspect
// peer connections and audio stats.
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
// Ensure AudioContexts auto-resume so the input-gain pipeline
// (source -> gain -> destination) never stalls in "suspended" state.
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
// Set deterministic voice settings so noise reduction and input gating
// don't swallow the fake audio tone.
const voiceSettings = JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settings: string) => {
localStorage.setItem('metoyou_voice_settings', settings);
}, voiceSettings);
await bob.page.addInitScript((settings: string) => {
localStorage.setItem('metoyou_voice_settings', settings);
}, voiceSettings);
// Seed Bob with an extra TURN entry before the app reads localStorage.
await bob.context.addInitScript((key: string) => {
try {
const existing = JSON.parse(localStorage.getItem(key) || '[]');
existing.push({
id: 'e2e-turn',
type: 'turn',
urls: 'turn:localhost:3478',
username: 'e2euser',
credential: 'e2epass'
});
localStorage.setItem(key, JSON.stringify(existing));
} catch { /* noop */ }
}, ICE_STORAGE_KEY);
await test.step('Register Alice', async () => {
const register = new RegisterPage(alice.page);
await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Register Bob', async () => {
const register = new RegisterPage(bob.page);
await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
});
await test.step('Alice creates a server', async () => {
const search = new ServerSearchPage(alice.page);
await search.createServer(serverName);
});
await test.step('Bob joins Alice server', async () => {
const search = new ServerSearchPage(bob.page);
await search.searchInput.fill(serverName);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
await test.step('Both join voice', async () => {
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
});
await test.step('Both users see each other in voice', async () => {
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
});
await test.step('Peer connections establish and audio flows bidirectionally', async () => {
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
// Wait for audio RTP stats to appear (tracks negotiated)
await waitForAudioStatsPresent(alice.page, 30_000);
await waitForAudioStatsPresent(bob.page, 30_000);
// Allow mesh to settle - voice routing and renegotiation can
// cause a second offer/answer cycle after the initial connection.
await alice.page.waitForTimeout(5_000);
// Chromium's --use-fake-device-for-media-stream can produce a
// silent capture track on the very first getUserMedia call. If
// bidirectional audio does not flow within a short window, leave
// and rejoin voice to re-acquire the mic (the second getUserMedia
// on a warm device always works).
let audioFlowing = false;
try {
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 15_000), waitForAllPeerAudioFlow(bob.page, 1, 15_000)]);
audioFlowing = true;
} catch {
// Silent sender detected - rejoin voice to work around Chromium bug
}
if (!audioFlowing) {
// Leave voice
await aliceRoom.disconnectButton.click();
await bobRoom.disconnectButton.click();
await alice.page.waitForTimeout(2_000);
// Rejoin
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
await waitForAudioStatsPresent(alice.page, 30_000);
await waitForAudioStatsPresent(bob.page, 30_000);
await alice.page.waitForTimeout(3_000);
}
// Final assertion - must succeed after the (optional) rejoin.
try {
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 60_000), waitForAllPeerAudioFlow(bob.page, 1, 60_000)]);
} catch (error) {
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
throw error;
}
});
await test.step('Bob still has TURN entry in localStorage', async () => {
const stored: StoredIceServerEntry[] = await bob.page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '[]') as StoredIceServerEntry[],
ICE_STORAGE_KEY
);
const hasTurn = stored.some(
(entry) => entry.type === 'turn' && entry.urls === 'turn:localhost:3478'
);
expect(hasTurn).toBe(true);
});
});
});

View File

@@ -0,0 +1,803 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
dumpRtcDiagnostics,
getConnectedPeerCount,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
// ── Signal endpoint identifiers ──────────────────────────────────────
const PRIMARY_SIGNAL_ID = 'e2e-mixed-signal-a';
const SECONDARY_SIGNAL_ID = 'e2e-mixed-signal-b';
// ── Room / channel names ─────────────────────────────────────────────
const VOICE_ROOM_NAME = `Mixed Signal Voice ${Date.now()}`;
const SECONDARY_ROOM_NAME = `Mixed Signal Chat ${Date.now()}`;
const VOICE_CHANNEL = 'General';
// ── User constants ───────────────────────────────────────────────────
const USER_PASSWORD = 'TestPass123!';
const USER_COUNT = 8;
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
const STABILITY_WINDOW_MS = 20_000;
// ── User signal configuration groups ─────────────────────────────────
//
// Group A (users 0-1): Both signal servers in network config (normal)
// Group B (users 2-3): Only primary signal - secondary NOT in config.
// They join the secondary room via invite link,
// which auto-adds the endpoint.
// Group C (users 4-5): Both signals initially, but secondary is removed
// after registration. They still see the room from
// search because the primary signal can discover it
// via findServerAcrossActiveEndpoints fallback.
// Group D (users 6-7): Only secondary signal in config. They join the
// primary room via invite link.
type SignalGroup = 'both' | 'primary-only' | 'both-then-remove-secondary' | 'secondary-only';
interface TestUser {
username: string;
displayName: string;
password: string;
group: SignalGroup;
}
type TestClient = Client & { user: TestUser };
function endpointsForGroup(
group: SignalGroup,
primaryUrl: string,
secondaryUrl: string
): SeededEndpointInput[] {
switch (group) {
case 'both':
return [
{
id: PRIMARY_SIGNAL_ID,
name: 'E2E Signal A',
url: primaryUrl,
isActive: true,
status: 'online'
},
{
id: SECONDARY_SIGNAL_ID,
name: 'E2E Signal B',
url: secondaryUrl,
isActive: true,
status: 'online'
}
];
case 'primary-only':
return [{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }];
case 'both-then-remove-secondary':
// Seed both initially; test will remove secondary after registration.
return [
{
id: PRIMARY_SIGNAL_ID,
name: 'E2E Signal A',
url: primaryUrl,
isActive: true,
status: 'online'
},
{
id: SECONDARY_SIGNAL_ID,
name: 'E2E Signal B',
url: secondaryUrl,
isActive: true,
status: 'online'
}
];
case 'secondary-only':
return [{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }];
}
}
test.describe('Mixed signal-config voice', () => {
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
createClient,
testServer
}) => {
test.setTimeout(720_000);
const secondaryServer = await startTestServer();
try {
const users = buildUsers();
const clients: TestClient[] = [];
// ── Create clients with per-group endpoint configs ───────────
for (const user of users) {
const client = await createClient();
const groupEndpoints = endpointsForGroup(user.group, testServer.url, secondaryServer.url);
await installTestServerEndpoints(client.context, groupEndpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
clients.push({ ...client, user });
}
// ── Register ─────────────────────────────────────────────────
await test.step('Register each user on their configured signal endpoint', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
const registrationEndpointId =
client.user.group === 'secondary-only' ? SECONDARY_SIGNAL_ID : PRIMARY_SIGNAL_ID;
await registerPage.goto();
await registerPage.serverSelect.selectOption(registrationEndpointId);
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
}
});
// ── Create rooms ────────────────────────────────────────────
await test.step('Create voice room on primary and chat room on secondary', async () => {
// Use a "both" user (client 0) to create both rooms
const searchPage = new ServerSearchPage(clients[0].page);
await searchPage.createServer(VOICE_ROOM_NAME, {
description: 'Voice room on primary signal',
sourceId: PRIMARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await searchPage.createServer(SECONDARY_ROOM_NAME, {
description: 'Chat room on secondary signal',
sourceId: SECONDARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
// ── Create invite links ─────────────────────────────────────
//
// Group B (primary-only) needs invite to secondary room.
// Group D (secondary-only) needs invite to primary room.
let primaryRoomInviteUrl: string;
let secondaryRoomInviteUrl: string;
await test.step('Create invite links for cross-signal rooms', async () => {
// Navigate to voice room to get its ID
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
const primaryRoomId = await getCurrentRoomId(clients[0].page);
const userId = await getCurrentUserId(clients[0].page);
// Navigate to secondary room to get its ID
await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME);
const secondaryRoomId = await getCurrentRoomId(clients[0].page);
// Create invite for primary room (voice) via API
const primaryInvite = await createInviteViaApi(
testServer.url,
primaryRoomId,
userId,
clients[0].user.displayName
);
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
// Create invite for secondary room (chat) via API
const secondaryInvite = await createInviteViaApi(
secondaryServer.url,
secondaryRoomId,
userId,
clients[0].user.displayName
);
secondaryRoomInviteUrl = `/invite/${secondaryInvite.id}?server=${encodeURIComponent(secondaryServer.url)}`;
});
// ── Remove secondary endpoint for group C ───────────────────
await test.step('Remove secondary signal from group C users', async () => {
for (const client of clients.filter((clientItem) => clientItem.user.group === 'both-then-remove-secondary')) {
await client.page.evaluate((primaryEndpoint) => {
localStorage.setItem('metoyou_server_endpoints', JSON.stringify([primaryEndpoint]));
}, { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, isDefault: false, status: 'online' });
}
});
// ── Join rooms ──────────────────────────────────────────────
await test.step('All users join the voice room (some via search, some via invite)', async () => {
for (const client of clients.slice(1)) {
if (client.user.group === 'secondary-only') {
// Group D: no primary signal -> join voice room via invite
await client.page.goto(primaryRoomInviteUrl);
await waitForInviteJoin(client.page);
} else {
// Groups A, B, C: have primary signal -> join via search
await joinRoomFromSearch(client.page, VOICE_ROOM_NAME);
}
}
// Navigate client 0 back to voice room
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
});
await test.step('All users also join the secondary chat room', async () => {
for (const client of clients.slice(1)) {
if (client.user.group === 'primary-only') {
// Group B: no secondary signal -> join chat room via invite
await client.page.goto(secondaryRoomInviteUrl);
await waitForInviteJoin(client.page);
} else if (client.user.group === 'secondary-only') {
// Group D: has secondary -> join via search
await openSearchView(client.page);
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
} else {
// Groups A, C: can search
await openSearchView(client.page);
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
}
}
// Ensure everyone navigates back to voice room
for (const client of clients) {
await openSavedRoomByName(client.page, VOICE_ROOM_NAME);
}
});
// ── Voice channel ───────────────────────────────────────────
await test.step('Create voice channel and join all 8 users', async () => {
const hostRoom = new ChatRoomPage(clients[0].page);
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
}
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
// ── Audio mesh ──────────────────────────────────────────────
await test.step('All users discover peers and audio flows pairwise', async () => {
await Promise.all(clients.map((client) =>
waitForPeerConnected(client.page, 45_000)
));
await Promise.all(clients.map((client) =>
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
await Promise.all(clients.map((client) =>
waitForAudioStatsPresent(client.page, 30_000)
));
await clients[0].page.waitForTimeout(5_000);
await Promise.all(clients.map((client) =>
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
});
// ── Voice workspace roster ──────────────────────────────────
await test.step('Voice workspace shows all 8 users on every client', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
// ── Stability + concurrent chat ─────────────────────────────
await test.step('Voice stays stable 20s while some users navigate and chat on other servers', async () => {
// Pick 2 users from different groups to navigate away and chat
const chatters = [clients[2], clients[6]]; // group C + group D
const stayers = clients.filter((clientItem) => !chatters.includes(clientItem));
// Chatters navigate to secondary room and send messages
for (const chatter of chatters) {
await openSavedRoomByName(chatter.page, SECONDARY_ROOM_NAME);
await expect(chatter.page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 10_000 });
}
const chatPage0 = new ChatMessagesPage(chatters[0].page);
const chatPage1 = new ChatMessagesPage(chatters[1].page);
await chatPage0.sendMessage(`Hello from ${chatters[0].user.displayName} while in voice!`);
await chatPage1.sendMessage(`Reply from ${chatters[1].user.displayName} also in voice!`);
// Verify messages arrive
await expect(
chatPage0.getMessageItemByText(`Reply from ${chatters[1].user.displayName}`)
).toBeVisible({ timeout: 15_000 });
await expect(
chatPage1.getMessageItemByText(`Hello from ${chatters[0].user.displayName}`)
).toBeVisible({ timeout: 15_000 });
// Meanwhile stability loop on all clients (including chatters - voice still active)
const deadline = Date.now() + STABILITY_WINDOW_MS;
while (Date.now() < deadline) {
for (const client of stayers) {
await expect.poll(async () => await getConnectedPeerCount(client.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
}
// Check chatters still have voice peers even while viewing another room
for (const chatter of chatters) {
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
}
if (Date.now() < deadline) {
await clients[0].page.waitForTimeout(5_000);
}
}
// Navigate chatters back to voice room
for (const chatter of chatters) {
await openSavedRoomByName(chatter.page, VOICE_ROOM_NAME);
}
// Verify audio still flowing after stability window
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
// ── Mute ────────────────────────────────────────────────────
await test.step('Mute state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
});
await test.step('Audio still flows on all peers after mute cycling', async () => {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
// ── Deafen ──────────────────────────────────────────────────
await test.step('Deafen state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.deafenButton.click();
await client.page.waitForTimeout(500);
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: true
});
await room.deafenButton.click();
await client.page.waitForTimeout(500);
// Un-deafen does NOT restore mute - user stays muted
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
}
});
await test.step('Unmute all users and verify audio flows end-to-end', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
} finally {
await secondaryServer.stop();
}
});
});
// ── User builders ────────────────────────────────────────────────────
function buildUsers(): TestUser[] {
const groups: SignalGroup[] = [
'both',
'both', // 0-1
'primary-only',
'primary-only', // 2-3
'both-then-remove-secondary',
'both-then-remove-secondary', // 4-5
'secondary-only',
'secondary-only' // 6-7
];
return groups.map((group, index) => ({
username: `mixed_sig_${Date.now()}_${index + 1}`,
displayName: `Mixed User ${index + 1}`,
password: USER_PASSWORD,
group
}));
}
// ── API helpers ──────────────────────────────────────────────────────
async function createInviteViaApi(
serverBaseUrl: string,
roomId: string,
userId: string,
displayName: string
): Promise<{ id: string }> {
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requesterUserId: userId,
requesterDisplayName: displayName
})
});
if (!response.ok) {
throw new Error(`Failed to create invite: ${response.status} ${await response.text()}`);
}
return await response.json() as { id: string };
}
async function getCurrentRoomId(page: Page): Promise<string> {
return await page.evaluate(() => {
interface RoomShape { id: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API unavailable');
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.();
if (!currentRoom?.id) {
throw new Error('No current room');
}
return currentRoom.id;
});
}
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface UserShape {
id: string;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API unavailable');
}
const component = debugApi.getComponent(host);
const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.();
if (!user?.id) {
throw new Error('Current user not found');
}
return user.id;
});
}
// ── Navigation helpers ───────────────────────────────────────────────
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...');
if (await searchInput.isVisible().catch(() => false)) {
return;
}
await page.locator('button[title="Create Server"]').click();
await expect(searchInput).toBeVisible({ timeout: 20_000 });
}
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('button', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForInviteJoin(page: Page): Promise<void> {
// Invite page loads -> auto-joins -> redirects to room
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
async function openVoiceWorkspace(page: Page): Promise<void> {
if (await page.locator('app-voice-workspace').isVisible()
.catch(() => false)) {
return;
}
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i })
.first();
await expect(viewButton).toBeVisible({ timeout: 10_000 });
await viewButton.click();
}
// ── Voice helpers ────────────────────────────────────────────────────
async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise<void> {
const room = new ChatRoomPage(page);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
await room.joinVoiceChannel(channelName);
try {
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000);
}
}
const lastErrorMessage = lastError instanceof Error
? `Last error: ${lastError.message}`
: 'Last error: unavailable';
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
}
async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(name) => {
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
interface UserShape { voiceState?: VoiceStateShape }
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { id: string; channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
const voiceState = currentUser?.voiceState;
return !!voiceChannel
&& voiceState?.isConnected === true
&& voiceState.roomId === voiceChannel.id
&& voiceState.serverId === currentRoom.id;
},
channelName,
{ timeout }
);
}
// ── Roster / state helpers ───────────────────────────────────────────
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-voice-workspace');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
return connectedUsers.length === count;
},
expectedCount,
{ timeout: 45_000 }
);
}
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
if (!channelId) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
return roster.length === expected;
},
{ expected: expectedCount, name: channelName },
{ timeout: 30_000 }
);
}
async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[],
displayName: string,
expectedState: { isMuted: boolean; isDeafened: boolean }
): Promise<void> {
for (const client of clients) {
await client.page.waitForFunction(
({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
interface VoiceStateShape { isMuted?: boolean; isDeafened?: boolean }
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
interface RoomShape { channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice');
if (!voiceChannel) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
return entry?.voiceState?.isMuted === expectedMuted
&& entry?.voiceState?.isDeafened === expectedDeafened;
},
{
expectedDisplayName: displayName,
expectedMuted: expectedState.isMuted,
expectedDeafened: expectedState.isDeafened
},
{ timeout: 30_000 }
);
}
}

View File

@@ -0,0 +1,763 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
dumpRtcDiagnostics,
getConnectedPeerCount,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
const PRIMARY_ROOM_NAME = `Dual Signal Voice A ${Date.now()}`;
const SECONDARY_ROOM_NAME = `Dual Signal Voice B ${Date.now()}`;
const VOICE_CHANNEL = 'General';
const USER_PASSWORD = 'TestPass123!';
const USER_COUNT = 8;
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
const STABILITY_WINDOW_MS = 20_000;
interface TestUser {
username: string;
displayName: string;
password: string;
}
type TestClient = Client & {
user: TestUser;
};
test.describe('Dual-signal multi-user voice', () => {
test('keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds', async ({
createClient,
testServer
}) => {
test.setTimeout(720_000);
const secondaryServer = await startTestServer();
try {
const endpoints: SeededEndpointInput[] = [
{
id: PRIMARY_SIGNAL_ID,
name: 'E2E Signal A',
url: testServer.url,
isActive: true,
status: 'online'
},
{
id: SECONDARY_SIGNAL_ID,
name: 'E2E Signal B',
url: secondaryServer.url,
isActive: true,
status: 'online'
}
];
const users = buildUsers();
const clients = await createTrackedClients(createClient, users, endpoints);
await test.step('Register every user with both active endpoints available', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
}
});
await test.step('Create primary and secondary rooms on different signal endpoints', async () => {
const searchPage = new ServerSearchPage(clients[0].page);
await searchPage.createServer(PRIMARY_ROOM_NAME, {
description: 'Primary signal room for 8-user voice mesh',
sourceId: PRIMARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await searchPage.createServer(SECONDARY_ROOM_NAME, {
description: 'Secondary signal room for dual-socket coverage',
sourceId: SECONDARY_SIGNAL_ID
});
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('Every user joins both rooms to keep 2 signal sockets open', async () => {
for (const client of clients.slice(1)) {
await joinRoomFromSearch(client.page, PRIMARY_ROOM_NAME);
}
for (const client of clients.slice(1)) {
await openSearchView(client.page);
await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
}
for (const client of clients) {
await openSavedRoomByName(client.page, PRIMARY_ROOM_NAME);
await waitForConnectedSignalManagerCount(client.page, 2);
}
});
await test.step('Create voice channel and join all 8 users', async () => {
const hostRoom = new ChatRoomPage(clients[0].page);
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
}
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
await test.step('All users discover all peers and audio flows pairwise', async () => {
// Wait for all clients to have at least one connected peer (fast)
await Promise.all(clients.map((client) =>
waitForPeerConnected(client.page, 45_000)
));
// Wait for all clients to have all 7 peers connected
await Promise.all(clients.map((client) =>
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
// Wait for audio stats to appear on all clients
await Promise.all(clients.map((client) =>
waitForAudioStatsPresent(client.page, 30_000)
));
// Allow the mesh to settle - voice routing, allowed-peer-id
// propagation and renegotiation all need time after the last
// user joins.
await clients[0].page.waitForTimeout(5_000);
// Check bidirectional audio flow on each client
await Promise.all(clients.map((client) =>
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
));
});
await test.step('Voice workspace and side panel show all 8 users on every client', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
await waitForConnectedSignalManagerCount(client.page, 2);
}
});
await test.step('Voice stays stable for more than 20 seconds across both signals', async () => {
const deadline = Date.now() + STABILITY_WINDOW_MS;
while (Date.now() < deadline) {
for (const client of clients) {
await expect.poll(async () => await getConnectedPeerCount(client.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(2);
}
if (Date.now() < deadline) {
await clients[0].page.waitForTimeout(5_000);
}
}
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
await test.step('Mute state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
});
await test.step('Audio still flows on all peers after mute cycling', async () => {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
await test.step('Deafen state propagates for every user across all clients', async () => {
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.deafenButton.click();
await client.page.waitForTimeout(500);
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: true
});
await room.deafenButton.click();
await client.page.waitForTimeout(500);
// Un-deafen does NOT restore mute - the user stays muted
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: true,
isDeafened: false
});
}
});
await test.step('Unmute all users and verify audio flows end-to-end', async () => {
// Every user is left muted after deafen cycling - unmute them all
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.muteButton.click();
await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
isMuted: false,
isDeafened: false
});
}
// Final audio flow check on every peer - confirms the full
// send/receive pipeline still works after mute+deafen cycling
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
} catch (error) {
console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
});
} finally {
await secondaryServer.stop();
}
});
});
function buildUsers(): TestUser[] {
return Array.from({ length: USER_COUNT }, (_value, index) => ({
username: `voice8_user_${Date.now()}_${index + 1}`,
displayName: `Voice User ${index + 1}`,
password: USER_PASSWORD
}));
}
async function createTrackedClients(
createClient: () => Promise<Client>,
users: TestUser[],
endpoints: readonly SeededEndpointInput[]
): Promise<TestClient[]> {
const clients: TestClient[] = [];
for (const user of users) {
const client = await createClient();
await installTestServerEndpoints(client.context, endpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
clients.push({
...client,
user
});
}
return clients;
}
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...');
if (await searchInput.isVisible().catch(() => false)) {
return;
}
await page.locator('button[title="Create Server"]').click();
await expect(searchInput).toBeVisible({ timeout: 20_000 });
}
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('button', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape {
name?: string;
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
async function openVoiceWorkspace(page: Page): Promise<void> {
const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i })
.first();
if (await page.locator('app-voice-workspace').isVisible()
.catch(() => false)) {
return;
}
await expect(viewButton).toBeVisible({ timeout: 10_000 });
await viewButton.click();
}
async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise<void> {
const room = new ChatRoomPage(page);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
await room.joinVoiceChannel(channelName);
try {
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000);
}
}
const diagnostics = await getVoiceJoinDiagnostics(page, channelName);
const displayName = diagnostics.currentUser?.displayName ?? 'Unknown user';
throw new Error([
`Failed to connect ${displayName} to voice channel ${channelName}.`,
lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable',
`Current room: ${diagnostics.currentRoom?.name ?? 'none'} (${diagnostics.currentRoom?.id ?? 'n/a'})`,
`Current user id: ${diagnostics.currentUser?.id ?? 'none'} / ${diagnostics.currentUser?.oderId ?? 'none'}`,
`Current user voice state: ${JSON.stringify(diagnostics.currentUser?.voiceState ?? null)}`,
`Voice channel id: ${diagnostics.voiceChannel?.id ?? 'missing'}`,
`Visible voice roster: ${diagnostics.voiceUsers.join(', ') || 'none'}`,
`Connected signaling managers: ${diagnostics.connectedSignalCount}`,
`Local voice facade state: ${JSON.stringify(diagnostics.localVoiceState)}`,
`Voice connection error: ${diagnostics.connectionErrorMessage ?? 'none'}`
].join('\n'));
}
async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(name) => {
interface VoiceStateShape {
isConnected?: boolean;
roomId?: string;
serverId?: string;
}
interface UserShape {
voiceState?: VoiceStateShape;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
id: string;
channels?: ChannelShape[];
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name);
const voiceState = currentUser?.voiceState;
return !!voiceChannel
&& voiceState?.isConnected === true
&& voiceState.roomId === voiceChannel.id
&& voiceState.serverId === currentRoom.id;
},
channelName,
{ timeout }
);
}
async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise<{
connectedSignalCount: number;
connectionErrorMessage: string | null;
currentRoom: { id?: string; name?: string } | null;
currentUser: { id?: string; oderId?: string; displayName?: string; voiceState?: Record<string, unknown> } | null;
localVoiceState: {
isVoiceConnected: boolean;
localStreamTracks: number;
rawMicTracks: number;
};
voiceChannel: { id?: string; name?: string } | null;
voiceUsers: string[];
}> {
return await page.evaluate((name) => {
interface VoiceStateShape {
isConnected?: boolean;
isMuted?: boolean;
isDeafened?: boolean;
roomId?: string;
serverId?: string;
}
interface UserShape {
id?: string;
oderId?: string;
displayName?: string;
voiceState?: VoiceStateShape;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
id?: string;
name?: string;
channels?: ChannelShape[];
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return {
connectedSignalCount: 0,
connectionErrorMessage: 'Angular debug API unavailable',
currentRoom: null,
currentUser: null,
localVoiceState: {
isVoiceConnected: false,
localStreamTracks: 0,
rawMicTracks: 0
},
voiceChannel: null,
voiceUsers: []
};
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name) ?? null;
const voiceUsers = voiceChannel
? ((component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [])
.map((user) => user.displayName ?? 'Unknown user')
: [];
const voiceConnection = component['voiceConnection'] as {
getLocalStream?: () => MediaStream | null;
getRawMicStream?: () => MediaStream | null;
isVoiceConnected?: () => boolean;
} | undefined;
const realtime = component['realtime'] as {
connectionErrorMessage?: () => string | null;
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
return {
connectedSignalCount: realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0,
connectionErrorMessage: realtime?.connectionErrorMessage?.() ?? null,
currentRoom,
currentUser,
localVoiceState: {
isVoiceConnected: voiceConnection?.isVoiceConnected?.() ?? false,
localStreamTracks: voiceConnection?.getLocalStream?.()?.getTracks().length ?? 0,
rawMicTracks: voiceConnection?.getRawMicStream?.()?.getTracks().length ?? 0
},
voiceChannel,
voiceUsers
};
}, channelName);
}
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const realtime = component['realtime'] as {
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
return countValue === count;
},
expectedCount,
{ timeout: 30_000 }
);
}
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return 0;
}
const component = debugApi.getComponent(host);
const realtime = component['realtime'] as {
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
});
}
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-voice-workspace');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
return connectedUsers.length === count;
},
expectedCount,
{ timeout: 45_000 }
);
}
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
channels?: ChannelShape[];
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
if (!channelId) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
return roster.length === expected;
},
{ expected: expectedCount, name: channelName },
{ timeout: 30_000 }
);
}
async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[],
displayName: string,
expectedState: { isMuted: boolean; isDeafened: boolean }
): Promise<void> {
for (const client of clients) {
await client.page.waitForFunction(
({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
interface VoiceStateShape {
isMuted?: boolean;
isDeafened?: boolean;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface UserShape {
displayName: string;
voiceState?: VoiceStateShape;
}
interface RoomShape {
channels?: ChannelShape[];
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
if (!voiceChannel) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
const entry = roster.find((user) => user.displayName === expectedDisplayName);
return entry?.voiceState?.isMuted === expectedMuted
&& entry?.voiceState?.isDeafened === expectedDeafened;
},
{
expectedDisplayName: displayName,
expectedMuted: expectedState.isMuted,
expectedDeafened: expectedState.isDeafened
},
{ timeout: 30_000 }
);
}
}

View File

@@ -1,6 +1,7 @@
import { test, expect } from '../../fixtures/multi-client'; import { test, expect } from '../../fixtures/multi-client';
import { import {
installWebRTCTracking, installWebRTCTracking,
installAutoResumeAudioContext,
waitForPeerConnected, waitForPeerConnected,
isPeerStillConnected, isPeerStillConnected,
getAudioStatsDelta, getAudioStatsDelta,
@@ -13,7 +14,7 @@ import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page'; import { ChatRoomPage } from '../../pages/chat-room.page';
/** /**
* Full user journey: register create server join voice verify audio * Full user journey: register -> create server -> join -> voice -> verify audio
* for 10+ seconds of stable connectivity. * for 10+ seconds of stable connectivity.
* *
* Uses two independent browser contexts (Alice & Bob) to simulate real * Uses two independent browser contexts (Alice & Bob) to simulate real
@@ -25,7 +26,7 @@ const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'Test
const SERVER_NAME = `E2E Test Server ${Date.now()}`; const SERVER_NAME = `E2E Test Server ${Date.now()}`;
const VOICE_CHANNEL = 'General'; const VOICE_CHANNEL = 'General';
test.describe('Full user journey: register server voice chat', () => { test.describe('Full user journey: register -> server -> voice chat', () => {
test('two users register, create server, join voice, and stay connected 10+ seconds with audio', async ({ createClient }) => { test('two users register, create server, join voice, and stay connected 10+ seconds with audio', async ({ createClient }) => {
test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check
@@ -35,6 +36,20 @@ test.describe('Full user journey: register → server → voice chat', () => {
// Install WebRTC tracking before any navigation // Install WebRTC tracking before any navigation
await installWebRTCTracking(alice.page); await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page); await installWebRTCTracking(bob.page);
await installAutoResumeAudioContext(alice.page);
await installAutoResumeAudioContext(bob.page);
// Seed deterministic voice settings so noise reduction doesn't
// swallow the fake audio tone.
const voiceSettings = JSON.stringify({
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
latencyProfile: 'balanced', includeSystemAudio: false,
noiseReduction: false, screenShareQuality: 'balanced',
askScreenShareQuality: false
});
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
// Forward browser console for debugging // Forward browser console for debugging
alice.page.on('console', msg => console.log('[Alice]', msg.text())); alice.page.on('console', msg => console.log('[Alice]', msg.text()));
@@ -146,8 +161,38 @@ test.describe('Full user journey: register → server → voice chat', () => {
// ── Step 7: Verify audio is flowing in both directions ─────────── // ── Step 7: Verify audio is flowing in both directions ───────────
await test.step('Audio packets are flowing between Alice and Bob', async () => { await test.step('Audio packets are flowing between Alice and Bob', async () => {
const aliceDelta = await waitForAudioFlow(alice.page, 30_000); // Chromium's --use-fake-device-for-media-stream can produce a
const bobDelta = await waitForAudioFlow(bob.page, 30_000); // silent capture track on the very first getUserMedia call. If
// bidirectional audio doesn't flow within a short window, leave
// and rejoin voice to re-acquire the mic.
let aliceDelta = await waitForAudioFlow(alice.page, 15_000);
let bobDelta = await waitForAudioFlow(bob.page, 15_000);
const isFlowing = (delta: typeof aliceDelta) =>
(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) &&
(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
if (!isFlowing(aliceDelta) || !isFlowing(bobDelta)) {
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
await aliceRoom.disconnectButton.click();
await bobRoom.disconnectButton.click();
await alice.page.waitForTimeout(2_000);
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForAudioStatsPresent(alice.page, 20_000);
await waitForAudioStatsPresent(bob.page, 20_000);
aliceDelta = await waitForAudioFlow(alice.page, 30_000);
bobDelta = await waitForAudioFlow(bob.page, 30_000);
}
if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0 if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) { || bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {

31
electron/README.md Normal file
View File

@@ -0,0 +1,31 @@
# Electron Shell
Electron main-process package for MetoYou / Toju. This directory owns desktop bootstrap, the preload bridge, IPC handlers, desktop persistence glue, updater integration, and window-level behavior.
## Commands
- `npm run build:electron` builds the Electron TypeScript output to `dist/electron`.
- `npm run electron` builds the product client and Electron, then launches the desktop app.
- `npm run electron:dev` starts the Angular client and Electron together.
- `npm run dev` starts the full desktop stack: server, Angular client, and Electron.
- `npm run electron:build`, `npm run electron:build:win`, `npm run electron:build:mac`, and `npm run electron:build:linux` create packaged desktop builds.
## Structure
| Path | Description |
| --- | --- |
| `main.ts` | Electron app bootstrap and process entry point |
| `preload.ts` | Typed renderer-facing preload bridge |
| `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings |
| `db/`, `entities/`, `migrations/` | Desktop persistence and schema evolution |
| `audio/` | Desktop audio integrations |
| `update/` | Desktop updater flow |
| `window/` | Window creation and window-level behavior |
## Notes
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
- Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -19,6 +19,7 @@ import {
setupSystemHandlers, setupSystemHandlers,
setupWindowControlHandlers setupWindowControlHandlers
} from '../ipc'; } from '../ipc';
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
export function registerAppLifecycle(): void { export function registerAppLifecycle(): void {
app.whenReady().then(async () => { app.whenReady().then(async () => {
@@ -34,6 +35,7 @@ export function registerAppLifecycle(): void {
await synchronizeAutoStartSetting(); await synchronizeAutoStartSetting();
initializeDesktopUpdater(); initializeDesktopUpdater();
await createWindow(); await createWindow();
startIdleMonitor();
app.on('activate', () => { app.on('activate', () => {
if (getMainWindow()) { if (getMainWindow()) {
@@ -57,6 +59,7 @@ export function registerAppLifecycle(): void {
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();
shutdownDesktopUpdater(); shutdownDesktopUpdater();
stopIdleMonitor();
await cleanupLinuxScreenShareAudioRouting(); await cleanupLinuxScreenShareAudioRouting();
await destroyDatabase(); await destroyDatabase();
app.quit(); app.quit();

View File

@@ -10,7 +10,12 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
oderId: user.oderId ?? null, oderId: user.oderId ?? null,
username: user.username ?? null, username: user.username ?? null,
displayName: user.displayName ?? null, displayName: user.displayName ?? null,
description: user.description ?? null,
profileUpdatedAt: user.profileUpdatedAt ?? null,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
avatarHash: user.avatarHash ?? null,
avatarMime: user.avatarMime ?? null,
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
status: user.status ?? null, status: user.status ?? null,
role: user.role ?? null, role: user.role ?? null,
joinedAt: user.joinedAt ?? null, joinedAt: user.joinedAt ?? null,

View File

@@ -46,7 +46,12 @@ export function rowToUser(row: UserEntity) {
oderId: row.oderId ?? '', oderId: row.oderId ?? '',
username: row.username ?? '', username: row.username ?? '',
displayName: row.displayName ?? '', displayName: row.displayName ?? '',
description: row.description ?? undefined,
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
avatarUrl: row.avatarUrl ?? undefined, avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
status: row.status ?? 'offline', status: row.status ?? 'offline',
role: row.role ?? 'member', role: row.role ?? 'member',
joinedAt: row.joinedAt ?? 0, joinedAt: row.joinedAt ?? 0,

View File

@@ -66,7 +66,12 @@ export interface RoomMemberRecord {
oderId?: string; oderId?: string;
username: string; username: string;
displayName: string; displayName: string;
description?: string;
profileUpdatedAt?: number;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
role: RoomMemberRole; role: RoomMemberRole;
roleIds?: string[]; roleIds?: string[];
joinedAt: number; joinedAt: number;
@@ -335,19 +340,33 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now); const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
const username = trimmedString(rawMember, 'username'); const username = trimmedString(rawMember, 'username');
const displayName = trimmedString(rawMember, 'displayName'); const displayName = trimmedString(rawMember, 'displayName');
const description = trimmedString(rawMember, 'description');
const profileUpdatedAt = isFiniteNumber(rawMember['profileUpdatedAt']) ? rawMember['profileUpdatedAt'] : undefined;
const avatarUrl = trimmedString(rawMember, 'avatarUrl'); const avatarUrl = trimmedString(rawMember, 'avatarUrl');
const avatarHash = trimmedString(rawMember, 'avatarHash');
return { const avatarMime = trimmedString(rawMember, 'avatarMime');
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
const member: RoomMemberRecord = {
id: normalizedId || normalizedKey, id: normalizedId || normalizedKey,
oderId: normalizedOderId || undefined, oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }), username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }), displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
profileUpdatedAt,
avatarUrl: avatarUrl || undefined, avatarUrl: avatarUrl || undefined,
avatarHash: avatarHash || undefined,
avatarMime: avatarMime || undefined,
avatarUpdatedAt,
role: normalizeRoomMemberRole(rawMember['role']), role: normalizeRoomMemberRole(rawMember['role']),
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined), roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
joinedAt, joinedAt,
lastSeenAt lastSeenAt
}; };
if (Object.prototype.hasOwnProperty.call(rawMember, 'description')) {
member.description = description || undefined;
}
return member;
} }
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord { function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
@@ -356,6 +375,16 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
} }
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt; const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
const existingProfileUpdatedAt = existingMember.profileUpdatedAt ?? 0;
const incomingProfileUpdatedAt = incomingMember.profileUpdatedAt ?? 0;
const preferIncomingProfile = incomingProfileUpdatedAt === existingProfileUpdatedAt
? preferIncoming
: incomingProfileUpdatedAt > existingProfileUpdatedAt;
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
? preferIncoming
: incomingAvatarUpdatedAt > existingAvatarUpdatedAt;
return { return {
id: existingMember.id || incomingMember.id, id: existingMember.id || incomingMember.id,
@@ -363,12 +392,23 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
username: preferIncoming username: preferIncoming
? (incomingMember.username || existingMember.username) ? (incomingMember.username || existingMember.username)
: (existingMember.username || incomingMember.username), : (existingMember.username || incomingMember.username),
displayName: preferIncoming displayName: preferIncomingProfile
? (incomingMember.displayName || existingMember.displayName) ? (incomingMember.displayName || existingMember.displayName)
: (existingMember.displayName || incomingMember.displayName), : (existingMember.displayName || incomingMember.displayName),
avatarUrl: preferIncoming description: preferIncomingProfile
? (Object.prototype.hasOwnProperty.call(incomingMember, 'description') ? incomingMember.description : existingMember.description)
: existingMember.description,
profileUpdatedAt: Math.max(existingProfileUpdatedAt, incomingProfileUpdatedAt) || undefined,
avatarUrl: preferIncomingAvatar
? (incomingMember.avatarUrl || existingMember.avatarUrl) ? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl), : (existingMember.avatarUrl || incomingMember.avatarUrl),
avatarHash: preferIncomingAvatar
? (incomingMember.avatarHash || existingMember.avatarHash)
: (existingMember.avatarHash || incomingMember.avatarHash),
avatarMime: preferIncomingAvatar
? (incomingMember.avatarMime || existingMember.avatarMime)
: (existingMember.avatarMime || incomingMember.avatarMime),
avatarUpdatedAt: Math.max(existingAvatarUpdatedAt, incomingAvatarUpdatedAt) || undefined,
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming), role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
roleIds: preferIncoming roleIds: preferIncoming
? (incomingMember.roleIds || existingMember.roleIds) ? (incomingMember.roleIds || existingMember.roleIds)
@@ -759,7 +799,12 @@ export async function replaceRoomRelations(
oderId: member.oderId ?? null, oderId: member.oderId ?? null,
username: member.username, username: member.username,
displayName: member.displayName, displayName: member.displayName,
description: member.description ?? null,
profileUpdatedAt: member.profileUpdatedAt ?? null,
avatarUrl: member.avatarUrl ?? null, avatarUrl: member.avatarUrl ?? null,
avatarHash: member.avatarHash ?? null,
avatarMime: member.avatarMime ?? null,
avatarUpdatedAt: member.avatarUpdatedAt ?? null,
role: member.role, role: member.role,
joinedAt: member.joinedAt, joinedAt: member.joinedAt,
lastSeenAt: member.lastSeenAt lastSeenAt: member.lastSeenAt
@@ -906,7 +951,12 @@ export async function loadRoomRelationsMap(
oderId: row.oderId ?? undefined, oderId: row.oderId ?? undefined,
username: row.username, username: row.username,
displayName: row.displayName, displayName: row.displayName,
description: row.description ?? undefined,
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
avatarUrl: row.avatarUrl ?? undefined, avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
role: row.role, role: row.role,
joinedAt: row.joinedAt, joinedAt: row.joinedAt,
lastSeenAt: row.lastSeenAt lastSeenAt: row.lastSeenAt

View File

@@ -105,7 +105,12 @@ export interface UserPayload {
oderId?: string; oderId?: string;
username?: string; username?: string;
displayName?: string; displayName?: string;
description?: string;
profileUpdatedAt?: number;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
status?: string; status?: string;
role?: string; role?: string;
joinedAt?: number; joinedAt?: number;

View File

@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto';
import { app } from 'electron'; import { app } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
@@ -20,23 +21,93 @@ import {
import { settings } from '../settings'; import { settings } from '../settings';
let applicationDataSource: DataSource | undefined; let applicationDataSource: DataSource | undefined;
let dbFilePath = '';
let dbBackupPath = '';
// SQLite files start with this 16-byte header string.
const SQLITE_MAGIC = 'SQLite format 3\0';
export function getDataSource(): DataSource | undefined { export function getDataSource(): DataSource | undefined {
return applicationDataSource; return applicationDataSource;
} }
/**
* Returns true when `data` looks like a valid SQLite file
* (correct header magic and at least one complete page).
*/
function isValidSqlite(data: Uint8Array): boolean {
if (data.length < 100)
return false;
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
return header === SQLITE_MAGIC;
}
/**
* Back up the current DB file so there is always a recovery point.
* If the main file is corrupted/empty but a valid backup exists,
* restore the backup before the app loads the database.
*/
function safeguardDbFile(): Uint8Array | undefined {
if (!fs.existsSync(dbFilePath))
return undefined;
const data = new Uint8Array(fs.readFileSync(dbFilePath));
if (isValidSqlite(data)) {
fs.copyFileSync(dbFilePath, dbBackupPath);
console.log('[DB] Backed up database to', dbBackupPath);
return data;
}
console.warn(`[DB] ${dbFilePath} appears corrupt (${data.length} bytes) - checking backup`);
if (fs.existsSync(dbBackupPath)) {
const backup = new Uint8Array(fs.readFileSync(dbBackupPath));
if (isValidSqlite(backup)) {
fs.copyFileSync(dbBackupPath, dbFilePath);
console.warn('[DB] Restored database from backup', dbBackupPath);
return backup;
}
console.error('[DB] Backup is also invalid - starting with a fresh database');
} else {
console.error('[DB] No backup available - starting with a fresh database');
}
return undefined;
}
/**
* Write the database to disk atomically: write a temp file first,
* then rename it over the real file. rename() is atomic on the same
* filesystem, so a crash mid-write can never leave a half-written DB.
*/
async function atomicSave(data: Uint8Array): Promise<void> {
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
try {
await fsp.writeFile(tmpPath, Buffer.from(data));
await fsp.rename(tmpPath, dbFilePath);
} catch (err) {
await fsp.unlink(tmpPath).catch(() => {});
throw err;
}
}
export async function initializeDatabase(): Promise<void> { export async function initializeDatabase(): Promise<void> {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
const dbDir = path.join(userDataPath, 'metoyou'); const dbDir = path.join(userDataPath, 'metoyou');
await fsp.mkdir(dbDir, { recursive: true }); await fsp.mkdir(dbDir, { recursive: true });
const databaseFilePath = path.join(dbDir, settings.databaseName); dbFilePath = path.join(dbDir, settings.databaseName);
dbBackupPath = dbFilePath + '.bak';
let database: Uint8Array | undefined; const database = safeguardDbFile();
if (fs.existsSync(databaseFilePath)) {
database = fs.readFileSync(databaseFilePath);
}
applicationDataSource = new DataSource({ applicationDataSource = new DataSource({
type: 'sqljs', type: 'sqljs',
@@ -59,12 +130,12 @@ export async function initializeDatabase(): Promise<void> {
synchronize: false, synchronize: false,
logging: false, logging: false,
autoSave: true, autoSave: true,
location: databaseFilePath autoSaveCallback: atomicSave
}); });
try { try {
await applicationDataSource.initialize(); await applicationDataSource.initialize();
console.log('[DB] Connection initialised at:', databaseFilePath); console.log('[DB] Connection initialised at:', dbFilePath);
try { try {
await applicationDataSource.runMigrations(); await applicationDataSource.runMigrations();

View File

@@ -24,9 +24,24 @@ export class RoomMemberEntity {
@Column('text') @Column('text')
displayName!: string; displayName!: string;
@Column('text', { nullable: true })
description!: string | null;
@Column('integer', { nullable: true })
profileUpdatedAt!: number | null;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
avatarUrl!: string | null; avatarUrl!: string | null;
@Column('text', { nullable: true })
avatarHash!: string | null;
@Column('text', { nullable: true })
avatarMime!: string | null;
@Column('integer', { nullable: true })
avatarUpdatedAt!: number | null;
@Column('text') @Column('text')
role!: 'host' | 'admin' | 'moderator' | 'member'; role!: 'host' | 'admin' | 'moderator' | 'member';

View File

@@ -18,9 +18,24 @@ export class UserEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
displayName!: string | null; displayName!: string | null;
@Column('text', { nullable: true })
description!: string | null;
@Column('integer', { nullable: true })
profileUpdatedAt!: number | null;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
avatarUrl!: string | null; avatarUrl!: string | null;
@Column('text', { nullable: true })
avatarHash!: string | null;
@Column('text', { nullable: true })
avatarMime!: string | null;
@Column('integer', { nullable: true })
avatarUpdatedAt!: number | null;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
status!: string | null; status!: string | null;

View File

@@ -0,0 +1,124 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach
} from 'vitest';
// Mock Electron modules before importing the module under test
const mockGetSystemIdleTime = vi.fn(() => 0);
const mockSend = vi.fn();
const mockGetMainWindow = vi.fn(() => ({
isDestroyed: () => false,
webContents: { send: mockSend }
}));
vi.mock('electron', () => ({
powerMonitor: {
getSystemIdleTime: mockGetSystemIdleTime
}
}));
vi.mock('../window/create-window', () => ({
getMainWindow: mockGetMainWindow
}));
import {
startIdleMonitor,
stopIdleMonitor,
getIdleState
} from './idle-monitor';
describe('idle-monitor', () => {
beforeEach(() => {
vi.useFakeTimers();
mockGetSystemIdleTime.mockReturnValue(0);
mockSend.mockClear();
});
afterEach(() => {
stopIdleMonitor();
vi.useRealTimers();
});
it('returns active when idle time is below threshold', () => {
mockGetSystemIdleTime.mockReturnValue(0);
expect(getIdleState()).toBe('active');
});
it('returns idle when idle time exceeds 15 minutes', () => {
mockGetSystemIdleTime.mockReturnValue(15 * 60);
expect(getIdleState()).toBe('idle');
});
it('sends idle-state-changed to renderer when transitioning to idle', () => {
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'idle');
});
it('sends idle-state-changed to renderer when transitioning back to active', () => {
startIdleMonitor();
// Go idle
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
mockSend.mockClear();
// Go active
mockGetSystemIdleTime.mockReturnValue(5);
vi.advanceTimersByTime(10_000);
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'active');
});
it('does not fire duplicates when state stays the same', () => {
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
vi.advanceTimersByTime(10_000);
vi.advanceTimersByTime(10_000);
// Only one transition, so only one call
const idleCalls = mockSend.mock.calls.filter(
([channel, state]: [string, string]) => channel === 'idle-state-changed' && state === 'idle'
);
expect(idleCalls.length).toBe(1);
});
it('stops polling after stopIdleMonitor', () => {
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
mockSend.mockClear();
stopIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(0);
vi.advanceTimersByTime(10_000);
expect(mockSend).not.toHaveBeenCalled();
});
it('does not notify when main window is null', () => {
mockGetMainWindow.mockReturnValue(null);
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
expect(mockSend).not.toHaveBeenCalled();
mockGetMainWindow.mockReturnValue({
isDestroyed: () => false,
webContents: { send: mockSend }
});
});
});

View File

@@ -0,0 +1,49 @@
import { powerMonitor } from 'electron';
import { getMainWindow } from '../window/create-window';
const IDLE_THRESHOLD_SECONDS = 15 * 60; // 15 minutes
const POLL_INTERVAL_MS = 10_000; // Check every 10 seconds
let pollTimer: ReturnType<typeof setInterval> | null = null;
let wasIdle = false;
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
export type IdleState = 'active' | 'idle';
/**
* Starts polling `powerMonitor.getSystemIdleTime()` and notifies the
* renderer whenever the user transitions between active and idle.
*/
export function startIdleMonitor(): void {
if (pollTimer)
return;
pollTimer = setInterval(() => {
const idleSeconds = powerMonitor.getSystemIdleTime();
const isIdle = idleSeconds >= IDLE_THRESHOLD_SECONDS;
if (isIdle !== wasIdle) {
wasIdle = isIdle;
const state: IdleState = isIdle ? 'idle' : 'active';
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IDLE_STATE_CHANGED_CHANNEL, state);
}
}
}, POLL_INTERVAL_MS);
}
export function stopIdleMonitor(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
export function getIdleState(): IdleState {
const idleSeconds = powerMonitor.getSystemIdleTime();
return idleSeconds >= IDLE_THRESHOLD_SECONDS ? 'idle' : 'active';
}

View File

@@ -36,6 +36,7 @@ import {
} from '../update/desktop-updater'; } from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links'; import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start'; import { synchronizeAutoStartSetting } from '../app/auto-start';
import { getIdleState } from '../idle/idle-monitor';
import { import {
getMainWindow, getMainWindow,
getWindowIconPath, getWindowIconPath,
@@ -528,6 +529,7 @@ export function setupSystemHandlers(): void {
resolve(false); resolve(false);
} }
}); });
response.on('error', () => resolve(false)); response.on('error', () => resolve(false));
}); });
@@ -537,7 +539,12 @@ export function setupSystemHandlers(): void {
}); });
ipcMain.handle('context-menu-command', (_event, command: string) => { ipcMain.handle('context-menu-command', (_event, command: string) => {
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const; const allowedCommands = [
'cut',
'copy',
'paste',
'selectAll'
] as const;
if (!allowedCommands.includes(command as typeof allowedCommands[number])) { if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
return; return;
@@ -551,10 +558,22 @@ export function setupSystemHandlers(): void {
} }
switch (command) { switch (command) {
case 'cut': webContents.cut(); break; case 'cut':
case 'copy': webContents.copy(); break; webContents.cut();
case 'paste': webContents.paste(); break; break;
case 'selectAll': webContents.selectAll(); break; case 'copy':
webContents.copy();
break;
case 'paste':
webContents.paste();
break;
case 'selectAll':
webContents.selectAll();
break;
} }
}); });
ipcMain.handle('get-idle-state', () => {
return getIdleState();
});
} }

View File

@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProfileAvatarMetadata1000000000006 implements MigrationInterface {
name = 'AddProfileAvatarMetadata1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarHash" TEXT`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarMime" TEXT`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUpdatedAt" INTEGER`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarHash" TEXT`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarMime" TEXT`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarUpdatedAt" INTEGER`);
}
public async down(): Promise<void> {
// SQLite column removal requires table rebuilds. Keep rollback no-op.
}
}

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserProfileMetadata1000000000007 implements MigrationInterface {
name = 'AddUserProfileMetadata1000000000007';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "description" TEXT`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "profileUpdatedAt" INTEGER`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "description" TEXT`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "profileUpdatedAt" INTEGER`);
}
public async down(): Promise<void> {
// SQLite column removal requires table rebuilds. Keep rollback no-op.
}
}

View File

@@ -6,6 +6,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monit
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received'; const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -214,6 +215,9 @@ export interface ElectronAPI {
contextMenuCommand: (command: string) => Promise<void>; contextMenuCommand: (command: string) => Promise<void>;
copyImageToClipboard: (srcURL: string) => Promise<boolean>; copyImageToClipboard: (srcURL: string) => Promise<boolean>;
getIdleState: () => Promise<'active' | 'idle'>;
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
command: <T = unknown>(command: Command) => Promise<T>; command: <T = unknown>(command: Command) => Promise<T>;
query: <T = unknown>(query: Query) => Promise<T>; query: <T = unknown>(query: Query) => Promise<T>;
} }
@@ -333,6 +337,19 @@ const electronAPI: ElectronAPI = {
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command), contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL), copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
getIdleState: () => ipcRenderer.invoke('get-idle-state'),
onIdleStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: 'active' | 'idle') => {
listener(state);
};
ipcRenderer.on(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
command: (command) => ipcRenderer.invoke('cqrs:command', command), command: (command) => ipcRenderer.invoke('cqrs:command', command),
query: (query) => ipcRenderer.invoke('cqrs:query', query) query: (query) => ipcRenderer.invoke('cqrs:query', query)
}; };

View File

@@ -500,7 +500,7 @@ async function performUpdateCheck(
setDesktopUpdateState({ setDesktopUpdateState({
lastCheckedAt: Date.now(), lastCheckedAt: Date.now(),
status: 'checking', status: 'checking',
statusMessage: `Checking for MetoYou ${targetRelease.version}`, statusMessage: `Checking for MetoYou ${targetRelease.version}...`,
targetVersion: targetRelease.version targetVersion: targetRelease.version
}); });
@@ -687,7 +687,7 @@ export function initializeDesktopUpdater(): void {
setDesktopUpdateState({ setDesktopUpdateState({
status: 'checking', status: 'checking',
statusMessage: 'Checking for desktop updates' statusMessage: 'Checking for desktop updates...'
}); });
}); });
@@ -698,7 +698,7 @@ export function initializeDesktopUpdater(): void {
setDesktopUpdateState({ setDesktopUpdateState({
lastCheckedAt: Date.now(), lastCheckedAt: Date.now(),
status: 'downloading', status: 'downloading',
statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}`, statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}...`,
targetVersion: nextVersion targetVersion: nextVersion
}); });
}); });

View File

@@ -15,8 +15,37 @@ let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null; let tray: Tray | null = null;
let closeToTrayEnabled = true; let closeToTrayEnabled = true;
let appQuitting = false; let appQuitting = false;
let youtubeRequestHeadersConfigured = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
function ensureYoutubeEmbedRequestHeaders(): void {
if (youtubeRequestHeadersConfigured || !app.isPackaged) {
return;
}
youtubeRequestHeadersConfigured = true;
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: [
'https://www.youtube-nocookie.com/*',
'https://www.youtube.com/*',
'https://*.youtube.com/*',
'https://*.googlevideo.com/*',
'https://*.ytimg.com/*'
]
},
(details, callback) => {
const requestHeaders = { ...details.requestHeaders };
requestHeaders['Referer'] ??= YOUTUBE_EMBED_REFERRER;
callback({ requestHeaders });
}
);
}
function getAssetPath(...segments: string[]): string { function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged const basePath = app.isPackaged
@@ -163,6 +192,7 @@ export async function createWindow(): Promise<void> {
closeToTrayEnabled = readDesktopSettings().closeToTray; closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray(); ensureTray();
ensureYoutubeEmbedRequestHeaders();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: 1400,
@@ -210,6 +240,40 @@ export async function createWindow(): Promise<void> {
); );
} }
if (process.platform === 'win32') {
session.defaultSession.setDisplayMediaRequestHandler(
async (request, respond) => {
// On Windows the system picker (useSystemPicker: true) is preferred.
// This handler is only reached when the system picker is unavailable.
// Include loopback audio when the renderer requested it so that
// getDisplayMedia receives an audio track and the renderer-side
// restrictOwnAudio constraint can keep the app's own voice playback
// out of the captured stream.
try {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
const firstSource = sources[0];
if (firstSource) {
respond({
video: firstSource,
...(request.audioRequested ? { audio: 'loopback' } : {})
});
return;
}
} catch {
// desktopCapturer also unavailable
}
respond({});
},
{ useSystemPicker: true }
);
}
if (process.env['NODE_ENV'] === 'development') { if (process.env['NODE_ENV'] === 'development') {
const devUrl = process.env['SSL'] === 'true' const devUrl = process.env['SSL'] === 'true'
? 'https://localhost:4200' ? 'https://localhost:4200'

View File

@@ -5,41 +5,7 @@ const angular = require('angular-eslint');
const stylisticTs = require('@stylistic/eslint-plugin-ts'); const stylisticTs = require('@stylistic/eslint-plugin-ts');
const stylisticJs = require('@stylistic/eslint-plugin-js'); const stylisticJs = require('@stylistic/eslint-plugin-js');
const newlines = require('eslint-plugin-import-newlines'); const newlines = require('eslint-plugin-import-newlines');
const metoyouEslintRules = require('./tools/eslint-rules');
// Inline plugin: ban en dash (, U+2013) and em dash (—, U+2014) from source files
const noDashPlugin = {
rules: {
'no-unicode-dashes': {
meta: { fixable: 'code' },
create(context) {
const BANNED = [
{ char: '\u2013', name: 'en dash ()' },
{ char: '\u2014', name: 'em dash (—)' }
];
return {
Program() {
const src = context.getSourceCode().getText();
for (const { char, name } of BANNED) {
let idx = src.indexOf(char);
while (idx !== -1) {
const start = idx;
const end = idx + char.length;
context.report({
loc: context.getSourceCode().getLocFromIndex(idx),
message: `Unicode ${name} is not allowed. Use a regular hyphen (-) instead.`,
fix(fixer) {
return fixer.replaceTextRange([start, end], '-');
}
});
idx = src.indexOf(char, idx + 1);
}
}
}
};
}
}
}
};
module.exports = tseslint.config( module.exports = tseslint.config(
{ {
@@ -51,7 +17,7 @@ module.exports = tseslint.config(
'@stylistic/ts': stylisticTs, '@stylistic/ts': stylisticTs,
'@stylistic/js': stylisticJs, '@stylistic/js': stylisticJs,
'import-newlines': newlines, 'import-newlines': newlines,
'no-dashes': noDashPlugin 'metoyou': metoyouEslintRules
}, },
extends: [ extends: [
eslint.configs.recommended, eslint.configs.recommended,
@@ -69,7 +35,7 @@ module.exports = tseslint.config(
styles: 0 styles: 0
} }
], ],
'no-dashes/no-unicode-dashes': 'error', 'metoyou/no-unicode-symbols': 'error',
'@typescript-eslint/no-extraneous-class': 'off', '@typescript-eslint/no-extraneous-class': 'off',
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ], '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
'@angular-eslint/directive-class-suffix': 'error', '@angular-eslint/directive-class-suffix': 'error',
@@ -123,7 +89,7 @@ module.exports = tseslint.config(
'complexity': ['warn',{ max:20 }], 'complexity': ['warn',{ max:20 }],
'curly': 'off', 'curly': 'off',
'eol-last': 'error', 'eol-last': 'error',
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'], 'id-denylist': ['warn','e','cb','i','c','any','string','String','Undefined','undefined','callback'],
'max-len': ['error',{ code:150, ignoreComments:true }], 'max-len': ['error',{ code:150, ignoreComments:true }],
'new-parens': 'error', 'new-parens': 'error',
'newline-per-chained-call': 'error', 'newline-per-chained-call': 'error',
@@ -172,7 +138,7 @@ module.exports = tseslint.config(
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); } // Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
'max-statements-per-line': ['error', { max: 1 }], 'max-statements-per-line': ['error', { max: 1 }],
// Prevent single-character identifiers for variables/params; do not check object property names // Prevent single-character identifiers for variables/params; do not check object property names
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }], 'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_', 'x', 'y'] }],
// Require blank lines around block-like statements (if, function, class, switch, try, etc.) // Require blank lines around block-like statements (if, function, class, switch, try, etc.)
'padding-line-between-statements': [ 'padding-line-between-statements': [
'error', 'error',
@@ -200,10 +166,10 @@ module.exports = tseslint.config(
// HTML template formatting rules (external Angular templates only) // HTML template formatting rules (external Angular templates only)
{ {
files: ['toju-app/src/app/**/*.html'], files: ['toju-app/src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin }, plugins: { 'metoyou': metoyouEslintRules },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: { rules: {
'no-dashes/no-unicode-dashes': 'error', 'metoyou/no-unicode-symbols': 'error',
// Angular template best practices // Angular template best practices
'@angular-eslint/template/button-has-type': 'warn', '@angular-eslint/template/button-has-type': 'warn',
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }], '@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],

373
package-lock.json generated
View File

@@ -60,6 +60,7 @@
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",
"@types/mocha": "^10.0.10",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0", "angular-eslint": "21.2.0",
@@ -79,6 +80,7 @@
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "8.50.1", "typescript-eslint": "8.50.1",
"vitest": "^4.1.4",
"wait-on": "^7.2.0" "wait-on": "^7.2.0"
} }
}, },
@@ -11025,6 +11027,17 @@
"@types/responselike": "^1.0.0" "@types/responselike": "^1.0.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -11306,6 +11319,13 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -11465,6 +11485,13 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/mocha": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
"integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -12270,6 +12297,146 @@
"vite": "^6.0.0 || ^7.0.0" "vite": "^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -13108,6 +13275,16 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/astral-regex": { "node_modules/astral-regex": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -14004,6 +14181,16 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -17483,6 +17670,16 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -17562,6 +17759,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exponential-backoff": { "node_modules/exponential-backoff": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -23538,6 +23745,17 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -27379,6 +27597,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -27773,6 +27998,13 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/stackframe": { "node_modules/stackframe": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
@@ -27798,6 +28030,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/stdin-discarder": { "node_modules/stdin-discarder": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
@@ -28671,6 +28910,13 @@
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -28696,6 +28942,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -30567,6 +30823,106 @@
"@esbuild/win32-x64": "0.25.12" "@esbuild/win32-x64": "0.25.12"
} }
}, },
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/vscode-jsonrpc": { "node_modules/vscode-jsonrpc": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
@@ -31439,6 +31795,23 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wildcard": { "node_modules/wildcard": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",

View File

@@ -17,7 +17,7 @@
"build:all": "npm run build && npm run build:electron && cd server && npm run build", "build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'", "build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
"watch": "cd \"toju-app\" && ng build --watch --configuration development", "watch": "cd \"toju-app\" && ng build --watch --configuration development",
"test": "cd \"toju-app\" && ng test", "test": "cd \"toju-app\" && vitest run",
"server:build": "cd server && npm run build", "server:build": "cd server && npm run build",
"server:start": "cd server && npm start", "server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev", "server:dev": "cd server && npm run dev",
@@ -110,6 +110,7 @@
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",
"@types/mocha": "^10.0.10",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0", "angular-eslint": "21.2.0",
@@ -129,6 +130,7 @@
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "8.50.1", "typescript-eslint": "8.50.1",
"vitest": "^4.1.4",
"wait-on": "^7.2.0" "wait-on": "^7.2.0"
}, },
"build": { "build": {

40
server/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Server
Node/TypeScript signaling server for MetoYou / Toju. This package owns the public server-directory API, join-request flows, websocket runtime, and server-side persistence.
## Install
1. Run `cd server`.
2. Run `npm install`.
## Commands
- `npm run dev` starts the server with `ts-node-dev` reload.
- `npm run build` compiles TypeScript to `dist/`.
- `npm run start` runs the compiled server.
- From the repository root, `npm run server:dev`, `npm run server:build`, and `npm run server:start` call the same package commands.
## Runtime Config
- The server loads the repository-root `.env` file on startup.
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
## Structure
| Path | Description |
| --- | --- |
| `src/index.ts` | Bootstrap and server startup |
| `src/app/` | Express app composition |
| `src/routes/` | REST API routes |
| `src/websocket/` | WebSocket runtime and signaling transport |
| `src/cqrs/` | Command/query handlers |
| `src/config/` | Runtime config loading and normalization |
| `src/db/`, `src/entities/`, `src/migrations/` | Persistence layer |
| `data/` | Runtime data files such as `variables.json` |
## Notes
- `dist/` and `../dist-server/` are generated output.
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.

View File

@@ -1,4 +1,6 @@
import { randomBytes } from 'crypto';
import fs from 'fs'; import fs from 'fs';
import fsp from 'fs/promises';
import path from 'path'; import path from 'path';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { import {
@@ -17,11 +19,77 @@ import {
import { serverMigrations } from '../migrations'; import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths'; import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
const DATA_DIR = resolveRuntimePath('data'); function resolveDbFile(): string {
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite'); const envPath = process.env.DB_PATH;
if (envPath) {
return path.resolve(envPath);
}
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
}
const DB_FILE = resolveDbFile();
const DB_BACKUP = DB_FILE + '.bak';
const DATA_DIR = path.dirname(DB_FILE);
// SQLite files start with this 16-byte header string.
const SQLITE_MAGIC = 'SQLite format 3\0';
let applicationDataSource: DataSource | undefined; let applicationDataSource: DataSource | undefined;
/**
* Returns true when `data` looks like a valid SQLite file
* (correct header magic and at least one complete page).
*/
function isValidSqlite(data: Uint8Array): boolean {
if (data.length < 100)
return false;
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
return header === SQLITE_MAGIC;
}
/**
* Back up the current DB file so there is always a recovery point.
* If the main file is corrupted/empty but a valid backup exists,
* restore the backup before the server loads the database.
*/
function safeguardDbFile(): Uint8Array | undefined {
if (!fs.existsSync(DB_FILE))
return undefined;
const data = new Uint8Array(fs.readFileSync(DB_FILE));
if (isValidSqlite(data)) {
// Good file - rotate it into the backup slot.
fs.copyFileSync(DB_FILE, DB_BACKUP);
console.log('[DB] Backed up database to', DB_BACKUP);
return data;
}
// The main file is corrupt or empty.
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
if (fs.existsSync(DB_BACKUP)) {
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
if (isValidSqlite(backup)) {
fs.copyFileSync(DB_BACKUP, DB_FILE);
console.warn('[DB] Restored database from backup', DB_BACKUP);
return backup;
}
console.error('[DB] Backup is also invalid - starting with a fresh database');
} else {
console.error('[DB] No backup available - starting with a fresh database');
}
return undefined;
}
function resolveSqlJsConfig(): { locateFile: (file: string) => string } { function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
return { return {
locateFile: (file) => { locateFile: (file) => {
@@ -35,6 +103,23 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
}; };
} }
/**
* Write the database to disk atomically: write a temp file first,
* then rename it over the real file. rename() is atomic on the same
* filesystem, so a crash mid-write can never leave a half-written DB.
*/
async function atomicSave(data: Uint8Array): Promise<void> {
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
try {
await fsp.writeFile(tmpPath, Buffer.from(data));
await fsp.rename(tmpPath, DB_FILE);
} catch (err) {
await fsp.unlink(tmpPath).catch(() => {});
throw err;
}
}
export function getDataSource(): DataSource { export function getDataSource(): DataSource {
if (!applicationDataSource?.isInitialized) { if (!applicationDataSource?.isInitialized) {
throw new Error('DataSource not initialised'); throw new Error('DataSource not initialised');
@@ -47,10 +132,7 @@ export async function initDatabase(): Promise<void> {
if (!fs.existsSync(DATA_DIR)) if (!fs.existsSync(DATA_DIR))
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
let database: Uint8Array | undefined; const database = safeguardDbFile();
if (fs.existsSync(DB_FILE))
database = fs.readFileSync(DB_FILE);
try { try {
applicationDataSource = new DataSource({ applicationDataSource = new DataSource({
@@ -73,7 +155,7 @@ export async function initDatabase(): Promise<void> {
synchronize: process.env.DB_SYNCHRONIZE === 'true', synchronize: process.env.DB_SYNCHRONIZE === 'true',
logging: false, logging: false,
autoSave: true, autoSave: true,
location: DB_FILE, autoSaveCallback: atomicSave,
sqlJsConfig: resolveSqlJsConfig() sqlJsConfig: resolveSqlJsConfig()
}); });
} catch (error) { } catch (error) {
@@ -94,7 +176,7 @@ export async function initDatabase(): Promise<void> {
await applicationDataSource.runMigrations(); await applicationDataSource.runMigrations();
console.log('[DB] Migrations executed'); console.log('[DB] Migrations executed');
} else { } else {
console.log('[DB] Synchronize mode migrations skipped'); console.log('[DB] Synchronize mode - migrations skipped');
} }
} }

View File

@@ -9,7 +9,7 @@ import { resolveCertificateDirectory, resolveEnvFilePath } from './runtime-paths
// Load .env from project root (one level up from server/) // Load .env from project root (one level up from server/)
dotenv.config({ path: resolveEnvFilePath() }); dotenv.config({ path: resolveEnvFilePath() });
import { initDatabase } from './db/database'; import { initDatabase, destroyDatabase } from './db/database';
import { deleteStaleJoinRequests } from './cqrs'; import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app'; import { createApp } from './app';
import { import {
@@ -59,6 +59,9 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
return createHttpServer(app); return createHttpServer(app);
} }
let listeningServer: ReturnType<typeof buildServer> | null = null;
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const variablesConfig = ensureVariablesConfig(); const variablesConfig = ensureVariablesConfig();
const serverProtocol = getServerProtocol(); const serverProtocol = getServerProtocol();
@@ -86,10 +89,12 @@ async function bootstrap(): Promise<void> {
const app = createApp(); const app = createApp();
const server = buildServer(app, serverProtocol); const server = buildServer(app, serverProtocol);
listeningServer = server;
setupWebSocket(server); setupWebSocket(server);
// Periodically clean up stale join requests (older than 24 h) // Periodically clean up stale join requests (older than 24 h)
setInterval(() => { staleJoinRequestInterval = setInterval(() => {
deleteStaleJoinRequests(24 * 60 * 60 * 1000) deleteStaleJoinRequests(24 * 60 * 60 * 1000)
.catch(err => console.error('Failed to clean up stale join requests:', err)); .catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000); }, 60 * 1000);
@@ -119,6 +124,45 @@ async function bootstrap(): Promise<void> {
} }
} }
let shuttingDown = false;
async function gracefulShutdown(signal: string): Promise<void> {
if (shuttingDown)
return;
shuttingDown = true;
if (staleJoinRequestInterval) {
clearInterval(staleJoinRequestInterval);
staleJoinRequestInterval = null;
}
console.log(`\n[Shutdown] ${signal} received - closing database...`);
if (listeningServer?.listening) {
try {
await new Promise<void>((resolve) => {
listeningServer?.close(() => resolve());
});
} catch (err) {
console.error('[Shutdown] Error closing server:', err);
}
}
listeningServer = null;
try {
await destroyDatabase();
} catch (err) {
console.error('[Shutdown] Error closing database:', err);
}
process.exit(0);
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
bootstrap().catch((err) => { bootstrap().catch((err) => {
console.error('Failed to start server:', err); console.error('Failed to start server:', err);
process.exit(1); process.exit(1);

View File

@@ -1,4 +1,3 @@
/* eslint-disable complexity */
import { Router } from 'express'; import { Router } from 'express';
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables'; import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
@@ -47,6 +46,11 @@ interface KlipyApiResponse {
}; };
} }
interface ResolvedGifMedia {
previewMeta: NormalizedMediaMeta | null;
sourceMeta: NormalizedMediaMeta;
}
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined { function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
for (const value of values) { for (const value of values) {
if (value != null) if (value != null)
@@ -130,33 +134,49 @@ function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext
}; };
} }
function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null {
const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd);
const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs);
const previewMeta = pickGifMeta(previewVariant);
const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta;
if (!sourceMeta?.url)
return null;
return {
previewMeta,
sourceMeta
};
}
function resolveGifSlug(gifItem: KlipyGifItem): string | undefined {
return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
}
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null { function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
if (!item || typeof item !== 'object') if (!item || typeof item !== 'object')
return null; return null;
const gifItem = item as KlipyGifItem; const gifItem = item as KlipyGifItem;
const resolvedMedia = resolveGifMedia(gifItem.file);
const slug = resolveGifSlug(gifItem);
if (gifItem.type === 'ad') if (gifItem.type === 'ad')
return null; return null;
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd); if (!slug || !resolvedMedia)
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
const lowMeta = pickGifMeta(lowVariant);
const highMeta = pickGifMeta(highVariant);
const selectedMeta = highMeta ?? lowMeta;
const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
if (!slug || !selectedMeta?.url)
return null; return null;
const { previewMeta, sourceMeta } = resolvedMedia;
return { return {
id: slug, id: slug,
slug, slug,
title: sanitizeString(gifItem.title), title: sanitizeString(gifItem.title),
url: selectedMeta.url, url: sourceMeta.url,
previewUrl: lowMeta?.url ?? selectedMeta.url, previewUrl: previewMeta?.url ?? sourceMeta.url,
width: selectedMeta.width ?? lowMeta?.width ?? 0, width: sourceMeta.width ?? previewMeta?.width ?? 0,
height: selectedMeta.height ?? lowMeta?.height ?? 0 height: sourceMeta.height ?? previewMeta?.height ?? 0
}; };
} }

View File

@@ -0,0 +1,295 @@
import {
describe,
it,
expect,
beforeEach,
vi
} from 'vitest';
import { connectedUsers } from './state';
import { handleWebSocketMessage } from './handler';
import { ConnectedUser } from './types';
import { WebSocket } from 'ws';
vi.mock('../services/server-access.service', () => ({
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
}));
/**
* Minimal mock WebSocket that records sent messages.
*/
function createMockWs(): WebSocket & { sentMessages: string[] } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => {},
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[] };
return ws;
}
function createConnectedUser(
connectionId: string,
oderId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const ws = createMockWs();
const user: ConnectedUser = {
oderId,
ws,
serverIds: new Set(),
displayName: 'Test User',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
function getRequiredConnectedUser(connectionId: string): ConnectedUser {
const connectedUser = connectedUsers.get(connectionId);
if (!connectedUser)
throw new Error(`Expected connected user for ${connectionId}`);
return connectedUser;
}
function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } {
return user.ws as unknown as { sentMessages: string[] };
}
describe('server websocket handler - status_update', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('updates user status on valid status_update message', async () => {
const user = createConnectedUser('conn-1', 'user-1');
user.serverIds.add('server-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
expect(connectedUsers.get('conn-1')?.status).toBe('away');
});
it('broadcasts status_update to other users in the same server', async () => {
const user1 = createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
user1.serverIds.add('server-1');
user2.serverIds.add('server-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update');
expect(statusMsg).toBeDefined();
expect(statusMsg?.oderId).toBe('user-1');
expect(statusMsg?.status).toBe('busy');
});
it('does not broadcast to users in different servers', async () => {
createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
getRequiredConnectedUser('conn-1').serverIds.add('server-1');
user2.serverIds.add('server-2');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
expect(getSentMessagesStore(user2).sentMessages.length).toBe(0);
});
it('ignores invalid status values', async () => {
createConnectedUser('conn-1', 'user-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'invalid_status' });
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
});
it('ignores missing status field', async () => {
createConnectedUser('conn-1', 'user-1');
await handleWebSocketMessage('conn-1', { type: 'status_update' });
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
});
it('accepts all valid status values', async () => {
for (const status of [
'online',
'away',
'busy',
'offline'
]) {
connectedUsers.clear();
createConnectedUser('conn-1', 'user-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status });
expect(connectedUsers.get('conn-1')?.status).toBe(status);
}
});
it('includes status in server_users response after status change', async () => {
const user1 = createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
user1.serverIds.add('server-1');
user2.serverIds.add('server-1');
// Set user-1 to away
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
// Clear sent messages
getSentMessagesStore(user2).sentMessages.length = 0;
// Identify first (required for handler)
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
// user-2 joins server -> should receive server_users with user-1's status
getSentMessagesStore(user2).sentMessages.length = 0;
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
expect(serverUsersMsg).toBeDefined();
const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
expect(user1InList?.status).toBe('away');
});
});
describe('server websocket handler - user_joined includes status', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('includes status in user_joined broadcast', async () => {
const user1 = createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
user1.serverIds.add('server-1');
user2.serverIds.add('server-1');
// Set user-1's status to busy before joining
getRequiredConnectedUser('conn-1').status = 'busy';
// Identify user-1
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
getSentMessagesStore(user2).sentMessages.length = 0;
// user-1 joins server-1
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
// user_joined may or may not appear depending on whether it's a new identity membership
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
if (joinMsg) {
expect(joinMsg.status).toBe('busy');
}
});
});
describe('server websocket handler - profile metadata in presence messages', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('broadcasts updated profile metadata when an identified user changes it', async () => {
const alice = createConnectedUser('conn-1', 'user-1', {
displayName: 'Alice',
viewedServerId: 'server-1'
});
const bob = createConnectedUser('conn-2', 'user-2', {
viewedServerId: 'server-1'
});
alice.serverIds.add('server-1');
bob.serverIds.add('server-1');
getSentMessagesStore(bob).sentMessages.length = 0;
await handleWebSocketMessage('conn-1', {
type: 'identify',
oderId: 'user-1',
displayName: 'Alice Updated',
description: 'Updated bio',
profileUpdatedAt: 789
});
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
expect(joinMsg?.displayName).toBe('Alice Updated');
expect(joinMsg?.description).toBe('Updated bio');
expect(joinMsg?.profileUpdatedAt).toBe(789);
expect(joinMsg?.serverId).toBe('server-1');
});
it('includes description and profileUpdatedAt in server_users responses', async () => {
const alice = createConnectedUser('conn-1', 'user-1');
const bob = createConnectedUser('conn-2', 'user-2');
alice.serverIds.add('server-1');
bob.serverIds.add('server-1');
await handleWebSocketMessage('conn-1', {
type: 'identify',
oderId: 'user-1',
displayName: 'Alice',
description: 'Alice bio',
profileUpdatedAt: 123
});
getSentMessagesStore(bob).sentMessages.length = 0;
await handleWebSocketMessage('conn-2', {
type: 'view_server',
serverId: 'server-1'
});
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
expect(aliceInList?.description).toBe('Alice bio');
expect(aliceInList?.profileUpdatedAt).toBe(123);
});
it('includes description and profileUpdatedAt in user_joined broadcasts', async () => {
const bob = createConnectedUser('conn-2', 'user-2');
bob.serverIds.add('server-1');
bob.viewedServerId = 'server-1';
createConnectedUser('conn-1', 'user-1', {
displayName: 'Alice',
description: 'Alice bio',
profileUpdatedAt: 456,
viewedServerId: 'server-1'
});
await handleWebSocketMessage('conn-1', {
type: 'join_server',
serverId: 'server-1'
});
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
expect(joinMsg?.description).toBe('Alice bio');
expect(joinMsg?.profileUpdatedAt).toBe(456);
});
});

View File

@@ -20,6 +20,22 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
return normalized || fallback; return normalized || fallback;
} }
function normalizeDescription(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
}
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: undefined;
}
function readMessageId(value: unknown): string | undefined { function readMessageId(value: unknown): string | undefined {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return undefined; return undefined;
@@ -37,7 +53,13 @@ function readMessageId(value: unknown): string | undefined {
/** Sends the current user list for a given server to a single connected user. */ /** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void { function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = getUniqueUsersInServer(serverId, user.oderId) const users = getUniqueUsersInServer(serverId, user.oderId)
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) })); .map(cu => ({
oderId: cu.oderId,
displayName: normalizeDisplayName(cu.displayName),
description: cu.description,
profileUpdatedAt: cu.profileUpdatedAt,
status: cu.status ?? 'online'
}));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
} }
@@ -45,6 +67,9 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const newOderId = readMessageId(message['oderId']) ?? connectionId; const newOderId = readMessageId(message['oderId']) ?? connectionId;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
const previousDisplayName = normalizeDisplayName(user.displayName);
const previousDescription = user.description;
const previousProfileUpdatedAt = user.profileUpdatedAt;
// Close stale connections from the same identity AND the same connection // Close stale connections from the same identity AND the same connection
// scope so offer routing always targets the freshest socket (e.g. after // scope so offer routing always targets the freshest socket (e.g. after
@@ -67,9 +92,38 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
user.oderId = newOderId; user.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
user.description = normalizeDescription(message['description']);
}
if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) {
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
}
user.connectionScope = newScope; user.connectionScope = newScope;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
if (
user.displayName === previousDisplayName
&& user.description === previousDescription
&& user.profileUpdatedAt === previousProfileUpdatedAt
) {
return;
}
for (const serverId of user.serverIds) {
broadcastToServer(serverId, {
type: 'user_joined',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId
}, user.oderId);
}
} }
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> { async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
@@ -108,6 +162,9 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
type: 'user_joined', type: 'user_joined',
oderId: user.oderId, oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName), displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId: sid serverId: sid
}, user.oderId); }, user.oderId);
} }
@@ -204,6 +261,32 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
} }
} }
const VALID_STATUSES = new Set([
'online',
'away',
'busy',
'offline'
]);
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
if (!status || !VALID_STATUSES.has(status))
return;
user.status = status as ConnectedUser['status'];
connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`);
for (const serverId of user.serverIds) {
broadcastToServer(serverId, {
type: 'status_update',
oderId: user.oderId,
status
}, user.oderId);
}
}
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> { export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
@@ -241,6 +324,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
handleTyping(user, message); handleTyping(user, message);
break; break;
case 'status_update':
handleStatusUpdate(user, message, connectionId);
break;
default: default:
console.log('Unknown message type:', message.type); console.log('Unknown message type:', message.type);
} }

View File

@@ -6,6 +6,8 @@ export interface ConnectedUser {
serverIds: Set<string>; serverIds: Set<string>;
viewedServerId?: string; viewedServerId?: string;
displayName?: string; displayName?: string;
description?: string;
profileUpdatedAt?: number;
/** /**
* Opaque scope string sent by the client (typically the signal URL it * Opaque scope string sent by the client (typically the signal URL it
* connected through). Stale-connection eviction only targets connections * connected through). Stale-connection eviction only targets connections
@@ -13,6 +15,8 @@ export interface ConnectedUser {
* URLs routing to the same server coexist without an eviction loop. * URLs routing to the same server coexist without an eviction loop.
*/ */
connectionScope?: string; connectionScope?: string;
/** User availability status (online, away, busy, offline). */
status?: 'online' | 'away' | 'busy' | 'offline';
/** Timestamp of the last pong received (used to detect dead connections). */ /** Timestamp of the last pong received (used to detect dead connections). */
lastPong: number; lastPong: number;
} }

View File

@@ -17,5 +17,5 @@
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
} }

42
toju-app/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Product Client
Angular 21 renderer for MetoYou / Toju. This package is managed from the repository root, so the main build, test, lint, and Electron integration commands are run there rather than from a local `package.json`.
## Commands
- `npm run start` starts the Angular dev server.
- `npm run build` builds the client to `dist/client`.
- `npm run watch` runs the Angular build in watch mode.
- `npm run test` runs the product-client Vitest suite.
- `npm run lint` runs ESLint across the repo.
- `npm run format` formats Angular HTML templates.
- `npm run sort:props` sorts Angular template properties.
- `npm run electron:dev` or `npm run dev` runs the client with Electron.
## Structure
| Path | Description |
| --- | --- |
| `src/app/domains/` | Bounded contexts and public domain entry points |
| `src/app/infrastructure/` | Shared technical runtime such as persistence and realtime |
| `src/app/shared-kernel/` | Cross-domain contracts and shared models |
| `src/app/features/` | App-level composition and transitional feature shells |
| `src/app/core/` | Platform adapters, compatibility entry points, and cross-domain technical helpers |
| `src/app/shared/` | Shared UI primitives and utilities |
| `src/app/store/` | NgRx reducers, effects, selectors, and actions |
| `public/` | Static assets copied into the Angular build |
## Key Docs
- [src/app/domains/README.md](src/app/domains/README.md)
- [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md)
- [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md)
- [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md)
- [../docs/architecture.md](../docs/architecture.md)
- [AGENTS.md](AGENTS.md)
## Notes
- `angular.json` defines build, serve, and lint targets for the product client.
- Product-client tests currently run through the root Vitest setup instead of an Angular `test` architect target.
- If the renderer-to-desktop contract changes, update the Angular bridge, Electron preload API, and IPC handlers together.

View File

@@ -97,7 +97,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "2.2MB", "maximumWarning": "2.2MB",
"maximumError": "2.3MB" "maximumError": "2.35MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

@@ -16,6 +16,7 @@ import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications'; import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UserAvatarEffects } from './store/users/user-avatar.effects';
import { UsersEffects } from './store/users/users.effects'; import { UsersEffects } from './store/users/users.effects';
import { RoomsEffects } from './store/rooms/rooms.effects'; import { RoomsEffects } from './store/rooms/rooms.effects';
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'; import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
@@ -38,6 +39,7 @@ export const appConfig: ApplicationConfig = {
NotificationsEffects, NotificationsEffects,
MessagesEffects, MessagesEffects,
MessagesSyncEffects, MessagesSyncEffects,
UserAvatarEffects,
UsersEffects, UsersEffects,
RoomsEffects, RoomsEffects,
RoomMembersSyncEffects, RoomMembersSyncEffects,

View File

@@ -28,7 +28,7 @@
@if (themeStudioFullscreenComponent()) { @if (themeStudioFullscreenComponent()) {
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" /> <ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
} @else { } @else {
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio</div> <div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio...</div>
} }
</div> </div>
} @else { @if (showDesktopUpdateNotice()) { } @else { @if (showDesktopUpdateNotice()) {

View File

@@ -33,13 +33,14 @@ import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform'; import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { ServersRailComponent } from './features/servers/servers-rail.component'; import { UserStatusService } from './core/services/user-status.service';
import { TitleBarComponent } from './features/shell/title-bar.component'; import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/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 { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.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'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component'; import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
import { UsersActions } from './store/users/users.actions'; import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions'; import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -92,6 +93,7 @@ export class App implements OnInit, OnDestroy {
readonly voiceSession = inject(VoiceSessionFacade); readonly voiceSession = inject(VoiceSessionFacade);
readonly externalLinks = inject(ExternalLinkService); readonly externalLinks = inject(ExternalLinkService);
readonly electronBridge = inject(ElectronBridgeService); readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null); readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null); readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -159,7 +161,7 @@ export class App implements OnInit, OnDestroy {
return; return;
} }
void import('./domains/theme/feature/settings/theme-settings.component') void import('./domains/theme/feature/settings/theme-settings/theme-settings.component')
.then((module) => { .then((module) => {
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent); this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
}); });
@@ -231,6 +233,8 @@ export class App implements OnInit, OnDestroy {
this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(UsersActions.loadCurrentUser());
this.userStatus.start();
this.store.dispatch(RoomsActions.loadRooms()); this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID); const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);

View File

@@ -5,6 +5,7 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings'; export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings'; export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_ICE_SERVERS = 'metoyou_ice_servers';
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active'; export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft'; export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';

View File

@@ -1,4 +1,3 @@
/* eslint-disable complexity, padding-line-between-statements */
import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics'; import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
import type { Room, User } from '../../models/index'; import type { Room, User } from '../../models/index';
import { import {
@@ -30,6 +29,14 @@ import type {
MutableDebugNetworkNode MutableDebugNetworkNode
} from '../../models/debugging.models'; } from '../../models/debugging.models';
interface FinalizedNodePresentation {
userId: string | null;
label: string;
secondaryLabel: string;
title: string;
isActive: boolean;
}
export function buildDebugNetworkSnapshot( export function buildDebugNetworkSnapshot(
entries: readonly DebugLogEntry[], entries: readonly DebugLogEntry[],
currentUser: User | null, currentUser: User | null,
@@ -158,36 +165,92 @@ class DebugNetworkSnapshotBuilder {
const payload = this.getEntryPayloadRecord(entry.payload); const payload = this.getEntryPayloadRecord(entry.payload);
const timestamp = entry.timestamp; const timestamp = entry.timestamp;
if ( if (this.applySignalingServerStateChange(state, entry.message, payload, timestamp))
entry.message === 'Connecting to signaling server'
|| entry.message === 'Connected to signaling server'
|| entry.message === 'Attempting reconnect'
|| entry.message === 'Disconnected from signaling server'
|| entry.message === 'Signaling socket error'
|| entry.message === 'Failed to initialize signaling socket'
) {
const url = this.getPayloadString(payload, 'serverUrl') ?? this.getPayloadString(payload, 'url');
if (!url)
return; return;
const serverNode = this.ensureSignalingServerNode(state, url, timestamp);
const edge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp);
edge.stateLabel = this.getSignalingEdgeStateLabel(entry.message);
edge.isActive = edge.stateLabel === 'connected' || edge.stateLabel === 'connecting' || edge.stateLabel === 'active';
return;
}
if (entry.message !== 'inbound' && entry.message !== 'outbound') if (entry.message !== 'inbound' && entry.message !== 'outbound')
return; return;
const direction = entry.message as DebugNetworkMessageDirection; const direction = entry.message as DebugNetworkMessageDirection;
const type = this.getPayloadString(payload, 'type') ?? 'unknown'; const type = this.getPayloadString(payload, 'type') ?? 'unknown';
this.recordSignalingTransportMessage(state, payload, type, direction, timestamp, entry.count);
switch (type) {
case 'identify':
this.applySignalingIdentifyMessage(state, direction, payload, timestamp);
return;
case 'connected':
this.applySignalingConnectedMessage(state, payload, timestamp);
return;
case 'join_server':
case 'view_server':
case 'leave_server':
this.applySignalingMembershipMessage(state, type, payload, timestamp);
return;
case 'server_users':
this.applySignalingServerUsersMessage(state, payload, timestamp);
return;
case 'user_joined':
case 'user_left':
case 'user_typing':
this.applySignalingUserActivityMessage(state, type, payload, timestamp);
return;
case 'typing':
this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
return;
case 'offer':
case 'answer':
case 'ice_candidate':
this.applySignalingPeerHandshakeMessage(state, type, direction, payload, timestamp, entry.count);
return;
}
}
private applySignalingServerStateChange(
state: DebugNetworkBuildState,
message: string,
payload: Record<string, unknown> | null,
timestamp: number
): boolean {
if (
message !== 'Connecting to signaling server'
&& message !== 'Connected to signaling server'
&& message !== 'Attempting reconnect'
&& message !== 'Disconnected from signaling server'
&& message !== 'Signaling socket error'
&& message !== 'Failed to initialize signaling socket'
) {
return false;
}
const url = this.getPayloadString(payload, 'serverUrl') ?? this.getPayloadString(payload, 'url');
if (!url)
return true;
const serverNode = this.ensureSignalingServerNode(state, url, timestamp);
const edge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp);
edge.stateLabel = this.getSignalingEdgeStateLabel(message);
edge.isActive = edge.stateLabel === 'connected' || edge.stateLabel === 'connecting' || edge.stateLabel === 'active';
return true;
}
private recordSignalingTransportMessage(
state: DebugNetworkBuildState,
payload: Record<string, unknown> | null,
type: string,
direction: DebugNetworkMessageDirection,
timestamp: number,
count: number
): void {
const url = this.getPayloadString(payload, 'url'); const url = this.getPayloadString(payload, 'url');
if (url) { if (!url)
return;
const signalingNode = this.ensureSignalingServerNode(state, url, timestamp); const signalingNode = this.ensureSignalingServerNode(state, url, timestamp);
const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp); const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp);
@@ -196,32 +259,41 @@ class DebugNetworkSnapshotBuilder {
if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected') if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected')
signalingEdge.stateLabel = 'active'; signalingEdge.stateLabel = 'active';
this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, entry.count); this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, count);
} }
switch (type) { private applySignalingIdentifyMessage(
case 'identify': { state: DebugNetworkBuildState,
direction: DebugNetworkMessageDirection,
payload: Record<string, unknown> | null,
timestamp: number
): void {
if (direction !== 'outbound')
return;
const oderId = this.getPayloadString(payload, 'oderId'); const oderId = this.getPayloadString(payload, 'oderId');
const displayName = this.getPayloadString(payload, 'displayName'); const displayName = this.getPayloadString(payload, 'displayName');
if (direction === 'outbound')
this.ensureLocalNetworkNode(state, timestamp, oderId, displayName); this.ensureLocalNetworkNode(state, timestamp, oderId, displayName);
break;
} }
case 'connected': { private applySignalingConnectedMessage(
state: DebugNetworkBuildState,
payload: Record<string, unknown> | null,
timestamp: number
): void {
const oderId = this.getPayloadString(payload, 'oderId'); const oderId = this.getPayloadString(payload, 'oderId');
if (oderId) if (oderId)
this.ensureLocalNetworkNode(state, timestamp, oderId); this.ensureLocalNetworkNode(state, timestamp, oderId);
break;
} }
case 'join_server': private applySignalingMembershipMessage(
case 'view_server': state: DebugNetworkBuildState,
case 'leave_server': { type: 'join_server' | 'view_server' | 'leave_server',
payload: Record<string, unknown> | null,
timestamp: number
): void {
const serverId = this.getPayloadString(payload, 'serverId'); const serverId = this.getPayloadString(payload, 'serverId');
if (!serverId) if (!serverId)
@@ -234,11 +306,13 @@ class DebugNetworkSnapshotBuilder {
membershipEdge.stateLabel = type === 'view_server' membershipEdge.stateLabel = type === 'view_server'
? 'viewing' ? 'viewing'
: (type === 'join_server' ? 'joined' : 'left'); : (type === 'join_server' ? 'joined' : 'left');
break;
} }
case 'server_users': { private applySignalingServerUsersMessage(
state: DebugNetworkBuildState,
payload: Record<string, unknown> | null,
timestamp: number
): void {
const serverId = this.getPayloadString(payload, 'serverId'); const serverId = this.getPayloadString(payload, 'serverId');
const users = this.getPayloadArray(payload, 'users'); const users = this.getPayloadArray(payload, 'users');
@@ -265,13 +339,14 @@ class DebugNetworkSnapshotBuilder {
membershipEdge.isActive = true; membershipEdge.isActive = true;
membershipEdge.stateLabel = 'joined'; membershipEdge.stateLabel = 'joined';
} }
break;
} }
case 'user_joined': private applySignalingUserActivityMessage(
case 'user_left': state: DebugNetworkBuildState,
case 'user_typing': { type: 'user_joined' | 'user_left' | 'user_typing',
payload: Record<string, unknown> | null,
timestamp: number
): void {
const oderId = this.getPayloadString(payload, 'oderId'); const oderId = this.getPayloadString(payload, 'oderId');
const displayName = this.getPayloadString(payload, 'displayName'); const displayName = this.getPayloadString(payload, 'displayName');
const serverId = this.getPayloadString(payload, 'serverId'); const serverId = this.getPayloadString(payload, 'serverId');
@@ -284,7 +359,9 @@ class DebugNetworkSnapshotBuilder {
if (type === 'user_typing') if (type === 'user_typing')
clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS; clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
if (serverId) { if (!serverId)
return;
const serverNode = this.ensureAppServerNode(state, serverId, timestamp); const serverNode = this.ensureAppServerNode(state, serverId, timestamp);
const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp); const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp);
@@ -292,16 +369,14 @@ class DebugNetworkSnapshotBuilder {
membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active'); membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active');
} }
break; private applySignalingPeerHandshakeMessage(
} state: DebugNetworkBuildState,
type: 'offer' | 'answer' | 'ice_candidate',
case 'typing': direction: DebugNetworkMessageDirection,
this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS; payload: Record<string, unknown> | null,
break; timestamp: number,
count: number
case 'offer': ): void {
case 'answer':
case 'ice_candidate': {
const peerId = direction === 'outbound' const peerId = direction === 'outbound'
? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId')) ? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'))
: (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId')); : (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId'));
@@ -313,28 +388,50 @@ class DebugNetworkSnapshotBuilder {
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, displayName); const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, displayName);
const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
this.incrementHandshakeStats(peerNode, type, direction, entry.count); this.incrementHandshakeStats(peerNode, type, direction, count);
this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, entry.count); this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, count);
peerEdge.isActive = true; peerEdge.isActive = true;
if (peerEdge.stateLabel !== 'connected') if (peerEdge.stateLabel !== 'connected')
peerEdge.stateLabel = 'negotiating'; peerEdge.stateLabel = 'negotiating';
break;
}
}
} }
private applyDataChannelNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { private applyDataChannelNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
const payload = this.getEntryPayloadRecord(entry.payload); const payload = this.getEntryPayloadRecord(entry.payload);
const timestamp = entry.timestamp; const timestamp = entry.timestamp;
if (entry.message === 'Peer latency updated') { if (this.applyDataChannelLatencyMessage(state, entry.message, payload, timestamp))
return;
if (this.applyDataChannelStateMessage(state, entry.message, payload, timestamp))
return;
if (entry.message !== 'inbound' && entry.message !== 'outbound')
return;
this.applyDataChannelPayloadMessage(
state,
payload,
entry.message as DebugNetworkMessageDirection,
timestamp,
entry.count
);
}
private applyDataChannelLatencyMessage(
state: DebugNetworkBuildState,
message: string,
payload: Record<string, unknown> | null,
timestamp: number
): boolean {
if (message !== 'Peer latency updated')
return false;
const peerId = this.getPayloadString(payload, 'peerId'); const peerId = this.getPayloadString(payload, 'peerId');
const latencyMs = this.getPayloadNumber(payload, 'latencyMs'); const latencyMs = this.getPayloadNumber(payload, 'latencyMs');
if (!peerId || latencyMs === null) if (!peerId || latencyMs === null)
return; return true;
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp);
const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
@@ -344,37 +441,48 @@ class DebugNetworkSnapshotBuilder {
edge.stateLabel = 'connected'; edge.stateLabel = 'connected';
peerNode.pingMs = latencyMs; peerNode.pingMs = latencyMs;
return; return true;
} }
if (entry.message === 'Data channel open' || entry.message === 'Data channel closed' || entry.message === 'Data channel error') { private applyDataChannelStateMessage(
state: DebugNetworkBuildState,
message: string,
payload: Record<string, unknown> | null,
timestamp: number
): boolean {
if (message !== 'Data channel open' && message !== 'Data channel closed' && message !== 'Data channel error')
return false;
const peerId = this.getPayloadString(payload, 'peerId'); const peerId = this.getPayloadString(payload, 'peerId');
if (!peerId) if (!peerId)
return; return true;
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp);
const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
if (entry.message === 'Data channel open') { if (message === 'Data channel open') {
edge.isActive = true; edge.isActive = true;
edge.stateLabel = 'connected'; edge.stateLabel = 'connected';
} else if (entry.message === 'Data channel closed') { return true;
}
edge.isActive = false; edge.isActive = false;
edge.stateLabel = 'closed'; edge.stateLabel = message === 'Data channel closed' ? 'closed' : 'error';
if (message === 'Data channel closed')
peerNode.isSpeaking = false; peerNode.isSpeaking = false;
} else {
edge.isActive = false; return true;
edge.stateLabel = 'error';
} }
return; private applyDataChannelPayloadMessage(
} state: DebugNetworkBuildState,
payload: Record<string, unknown> | null,
if (entry.message !== 'inbound' && entry.message !== 'outbound') direction: DebugNetworkMessageDirection,
return; timestamp: number,
count: number
const direction = entry.message as DebugNetworkMessageDirection; ): void {
const peerId = this.getPayloadString(payload, 'peerId'); const peerId = this.getPayloadString(payload, 'peerId');
if (!peerId) if (!peerId)
@@ -386,16 +494,16 @@ class DebugNetworkSnapshotBuilder {
edge.isActive = true; edge.isActive = true;
edge.stateLabel = 'connected'; edge.stateLabel = 'connected';
this.recordNetworkMessage(edge, type, direction, 'data-channel', timestamp, entry.count); this.recordNetworkMessage(edge, type, direction, 'data-channel', timestamp, count);
if (type === 'chat-message' || type === 'message') if (type === 'chat-message' || type === 'message')
this.incrementTextMessageCount(peerNode, direction, entry.count); this.incrementTextMessageCount(peerNode, direction, count);
if (type === 'file-chunk' && direction === 'inbound') { if (type === 'file-chunk' && direction === 'inbound') {
const bytes = this.getPayloadNumber(payload, 'bytes'); const bytes = this.getPayloadNumber(payload, 'bytes');
if (bytes !== null) if (bytes !== null)
this.recordFileTransferSample(peerNode, timestamp, bytes, entry.count); this.recordFileTransferSample(peerNode, timestamp, bytes, count);
} }
if (direction === 'outbound') { if (direction === 'outbound') {
@@ -412,6 +520,21 @@ class DebugNetworkSnapshotBuilder {
} }
if (type === 'voice-state') { if (type === 'voice-state') {
this.applyVoiceStateDataChannelMessage(state, payload, direction, peerNode, timestamp);
return;
}
if (type === 'screen-state' || type === 'camera-state')
this.applyStreamingStateDataChannelMessage(state, type, payload, direction, peerNode, timestamp);
}
private applyVoiceStateDataChannelMessage(
state: DebugNetworkBuildState,
payload: Record<string, unknown> | null,
direction: DebugNetworkMessageDirection,
peerNode: MutableDebugNetworkNode,
timestamp: number
): void {
const voiceState = this.getPayloadRecord(payload, 'voiceState'); const voiceState = this.getPayloadRecord(payload, 'voiceState');
const subjectNode = direction === 'outbound' const subjectNode = direction === 'outbound'
? this.ensureLocalNetworkNode( ? this.ensureLocalNetworkNode(
@@ -426,16 +549,24 @@ class DebugNetworkSnapshotBuilder {
const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId'); const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId');
if (serverId) { if (!serverId)
return;
const serverNode = this.ensureAppServerNode(state, serverId, timestamp); const serverNode = this.ensureAppServerNode(state, serverId, timestamp);
const membershipEdge = this.ensureNetworkEdge(state, 'membership', subjectNode.id, serverNode.id, timestamp); const membershipEdge = this.ensureNetworkEdge(state, 'membership', subjectNode.id, serverNode.id, timestamp);
membershipEdge.isActive = true; membershipEdge.isActive = true;
membershipEdge.stateLabel = 'joined'; membershipEdge.stateLabel = 'joined';
} }
}
if (type === 'screen-state' || type === 'camera-state') { private applyStreamingStateDataChannelMessage(
state: DebugNetworkBuildState,
type: 'screen-state' | 'camera-state',
payload: Record<string, unknown> | null,
direction: DebugNetworkMessageDirection,
peerNode: MutableDebugNetworkNode,
timestamp: number
): void {
const subjectNode = direction === 'outbound' const subjectNode = direction === 'outbound'
? this.ensureLocalNetworkNode( ? this.ensureLocalNetworkNode(
state, state,
@@ -448,14 +579,14 @@ class DebugNetworkSnapshotBuilder {
? this.getPayloadBoolean(payload, 'isScreenSharing') ? this.getPayloadBoolean(payload, 'isScreenSharing')
: this.getPayloadBoolean(payload, 'isCameraEnabled'); : this.getPayloadBoolean(payload, 'isCameraEnabled');
if (isStreaming !== null) { if (isStreaming === null)
return;
subjectNode.isStreaming = isStreaming; subjectNode.isStreaming = isStreaming;
if (!isStreaming) if (!isStreaming)
subjectNode.streams.video = 0; subjectNode.streams.video = 0;
} }
}
}
private applyGenericWebRTCNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { private applyGenericWebRTCNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
const payload = this.getEntryPayloadRecord(entry.payload); const payload = this.getEntryPayloadRecord(entry.payload);
@@ -471,14 +602,35 @@ class DebugNetworkSnapshotBuilder {
switch (entry.message) { switch (entry.message) {
case 'Creating peer connection': case 'Creating peer connection':
case 'Received data channel': case 'Received data channel':
this.markPeerEdgeConnecting(edge);
return;
case 'connectionstatechange':
this.applyGenericConnectionStateMessage(peerNode, edge, payload);
return;
case 'Remote stream updated':
this.applyGenericRemoteStreamMessage(peerNode, edge, payload);
return;
case 'Peer transport stats':
this.applyGenericTransportStatsMessage(peerNode, edge, payload, timestamp);
return;
}
}
private markPeerEdgeConnecting(edge: MutableDebugNetworkEdge): void {
edge.isActive = true; edge.isActive = true;
if (edge.stateLabel !== 'connected') if (edge.stateLabel !== 'connected')
edge.stateLabel = 'connecting'; edge.stateLabel = 'connecting';
}
return; private applyGenericConnectionStateMessage(
peerNode: MutableDebugNetworkNode,
case 'connectionstatechange': { edge: MutableDebugNetworkEdge,
payload: Record<string, unknown> | null
): void {
const connectionState = this.getStringProperty(payload, 'state'); const connectionState = this.getStringProperty(payload, 'state');
if (!connectionState) if (!connectionState)
@@ -489,22 +641,26 @@ class DebugNetworkSnapshotBuilder {
edge.stateLabel = connectionState; edge.stateLabel = connectionState;
edge.isActive = connectionState === 'connected' || connectionState === 'connecting'; edge.isActive = connectionState === 'connected' || connectionState === 'connecting';
if (!edge.isActive) { if (edge.isActive)
return;
peerNode.isSpeaking = false; peerNode.isSpeaking = false;
if (connectionState === 'disconnected' || connectionState === 'failed') { if (connectionState !== 'disconnected' && connectionState !== 'failed')
return;
if (previousState !== connectionState) if (previousState !== connectionState)
peerNode.connectionDrops += 1; peerNode.connectionDrops += 1;
peerNode.streams.audio = 0; peerNode.streams.audio = 0;
peerNode.streams.video = 0; peerNode.streams.video = 0;
} }
}
return; private applyGenericRemoteStreamMessage(
} peerNode: MutableDebugNetworkNode,
edge: MutableDebugNetworkEdge,
case 'Remote stream updated': { payload: Record<string, unknown> | null
): void {
const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount'); const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount');
const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount'); const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount');
@@ -518,11 +674,14 @@ class DebugNetworkSnapshotBuilder {
if (videoTrackCount !== null) if (videoTrackCount !== null)
peerNode.streams.video = Math.max(0, Math.round(videoTrackCount)); peerNode.streams.video = Math.max(0, Math.round(videoTrackCount));
return;
} }
case 'Peer transport stats': { private applyGenericTransportStatsMessage(
peerNode: MutableDebugNetworkNode,
edge: MutableDebugNetworkEdge,
payload: Record<string, unknown> | null,
timestamp: number
): void {
edge.isActive = true; edge.isActive = true;
if (!edge.stateLabel || edge.stateLabel === 'negotiating') if (!edge.stateLabel || edge.stateLabel === 'negotiating')
@@ -532,10 +691,6 @@ class DebugNetworkSnapshotBuilder {
audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'), audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'),
videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps') videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps')
}, timestamp); }, timestamp);
return;
}
}
} }
private applyVoiceActivityNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { private applyVoiceActivityNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
@@ -558,12 +713,31 @@ class DebugNetworkSnapshotBuilder {
const localVoiceServerId = localUser ? this.getUserVoiceServerId(localUser) : null; const localVoiceServerId = localUser ? this.getUserVoiceServerId(localUser) : null;
const localVoiceConnected = localUser?.voiceState?.isConnected === true; const localVoiceConnected = localUser?.voiceState?.isConnected === true;
if (localUser && localIdentity) { this.applyLiveLocalUserState(state, localUser, localIdentity, localVoiceServerId, localVoiceConnected, now);
for (const user of state.users) {
this.applyLiveRemoteUserState(state, user, localVoiceConnected, localVoiceServerId, now);
}
}
private applyLiveLocalUserState(
state: DebugNetworkBuildState,
localUser: User | null,
localIdentity: string | null,
localVoiceServerId: string | null,
localVoiceConnected: boolean,
now: number
): void {
if (!localUser || !localIdentity)
return;
const localNode = this.ensureLocalNetworkNode(state, now, localIdentity, localUser.displayName); const localNode = this.ensureLocalNetworkNode(state, now, localIdentity, localUser.displayName);
this.applyUserStateToNetworkNode(localNode, localUser, now); this.applyUserStateToNetworkNode(localNode, localUser, now);
if (localVoiceServerId) { if (!localVoiceServerId)
return;
const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now); const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now);
const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now); const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now);
@@ -571,13 +745,18 @@ class DebugNetworkSnapshotBuilder {
membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing'; membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing';
membershipEdge.lastSeen = now; membershipEdge.lastSeen = now;
} }
}
for (const user of state.users) { private applyLiveRemoteUserState(
state: DebugNetworkBuildState,
user: User,
localVoiceConnected: boolean,
localVoiceServerId: string | null,
now: number
): void {
const identity = this.getUserNetworkIdentity(user); const identity = this.getUserNetworkIdentity(user);
if (!identity) if (!identity)
continue; return;
const node = this.isLocalIdentity(state, identity) const node = this.isLocalIdentity(state, identity)
? this.ensureLocalNetworkNode(state, now, identity, user.displayName) ? this.ensureLocalNetworkNode(state, now, identity, user.displayName)
@@ -587,25 +766,19 @@ class DebugNetworkSnapshotBuilder {
const voiceServerId = this.getUserVoiceServerId(user); const voiceServerId = this.getUserVoiceServerId(user);
if (voiceServerId) { this.syncLiveUserMembershipEdge(state, node, voiceServerId, user.voiceState?.isConnected === true, now);
const serverNode = this.ensureAppServerNode(state, voiceServerId, now);
const membershipEdge = this.ensureNetworkEdge(state, 'membership', node.id, serverNode.id, now);
membershipEdge.isActive = user.voiceState?.isConnected === true;
membershipEdge.stateLabel = membershipEdge.isActive ? 'joined' : 'inactive';
if (membershipEdge.isActive)
membershipEdge.lastSeen = now;
}
if ( if (
!this.isLocalIdentity(state, identity) this.isLocalIdentity(state, identity)
&& localVoiceConnected || !localVoiceConnected
&& user.voiceState?.isConnected === true || user.voiceState?.isConnected !== true
&& voiceServerId || !voiceServerId
&& localVoiceServerId || !localVoiceServerId
&& voiceServerId === localVoiceServerId || voiceServerId !== localVoiceServerId
) { ) {
return;
}
const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, node.id, now); const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, node.id, now);
peerEdge.isActive = true; peerEdge.isActive = true;
@@ -615,7 +788,25 @@ class DebugNetworkSnapshotBuilder {
if (node.pingMs !== null) if (node.pingMs !== null)
peerEdge.pingMs = node.pingMs; peerEdge.pingMs = node.pingMs;
} }
}
private syncLiveUserMembershipEdge(
state: DebugNetworkBuildState,
node: MutableDebugNetworkNode,
voiceServerId: string | null,
isVoiceConnected: boolean,
now: number
): void {
if (!voiceServerId)
return;
const serverNode = this.ensureAppServerNode(state, voiceServerId, now);
const membershipEdge = this.ensureNetworkEdge(state, 'membership', node.id, serverNode.id, now);
membershipEdge.isActive = isVoiceConnected;
membershipEdge.stateLabel = isVoiceConnected ? 'joined' : 'inactive';
if (isVoiceConnected)
membershipEdge.lastSeen = now;
} }
private applyUserStateToNetworkNode(node: MutableDebugNetworkNode, user: User, now: number): void { private applyUserStateToNetworkNode(node: MutableDebugNetworkNode, user: User, now: number): void {
@@ -700,6 +891,7 @@ class DebugNetworkSnapshotBuilder {
node.label = currentRoom?.id === serverId node.label = currentRoom?.id === serverId
? currentRoom.name ? currentRoom.name
: (node.label || `Server ${this.shortenIdentifier(serverId)}`); : (node.label || `Server ${this.shortenIdentifier(serverId)}`);
node.secondaryLabel = serverId; node.secondaryLabel = serverId;
node.title = node.label; node.title = node.label;
@@ -898,6 +1090,7 @@ class DebugNetworkSnapshotBuilder {
bytes: bytes * count, bytes: bytes * count,
timestamp timestamp
}); });
node.fileTransferSamples = node.fileTransferSamples.filter( node.fileTransferSamples = node.fileTransferSamples.filter(
(sample) => timestamp - sample.timestamp <= NETWORK_FILE_TRANSFER_RATE_WINDOW_MS (sample) => timestamp - sample.timestamp <= NETWORK_FILE_TRANSFER_RATE_WINDOW_MS
); );
@@ -973,22 +1166,90 @@ class DebugNetworkSnapshotBuilder {
this.applyLivePeerMetricsToNetworkNode(node, node.identity, now); this.applyLivePeerMetricsToNetworkNode(node, node.identity, now);
const isTyping = node.typingExpiresAt !== null && node.typingExpiresAt > now; const isTyping = node.typingExpiresAt !== null && node.typingExpiresAt > now;
const user = node.identity ? state.userLookup.get(node.identity) : null; const user = node.identity ? state.userLookup.get(node.identity) : undefined;
const userId = node.userId || user?.id || null; const presentation = this.buildFinalizedNodePresentation(node, state, user, hasActiveEdge);
const label = node.kind === 'local-client' const streams = this.buildFinalizedNodeStreams(node);
? (state.currentUser?.displayName || state.currentUser?.username || node.label || 'You') const downloads = this.getFreshDownloadStats(node, now);
: (user?.displayName || user?.username || node.label); const statuses = this.buildFinalizedNodeStatuses(node, streams, isTyping, hasActiveEdge);
const secondaryLabel = node.kind === 'local-client'
? (state.currentUser?.username ? `@${state.currentUser.username}` : 'You') return {
: (user?.username ? `@${user.username}` : node.secondaryLabel); id: node.id,
const title = node.kind === 'local-client' identity: node.identity,
? 'Current client' userId: presentation.userId,
: node.title; kind: node.kind,
const streams = { label: presentation.label,
secondaryLabel: presentation.secondaryLabel,
title: presentation.title,
statuses,
isActive: presentation.isActive,
isVoiceConnected: node.isVoiceConnected,
isTyping,
isSpeaking: node.isSpeaking,
isMuted: node.isMuted,
isDeafened: node.isDeafened,
isStreaming: node.isStreaming,
connectionDrops: node.connectionDrops,
downloads,
handshake: { ...node.handshake },
pingMs: node.pingMs,
streams,
textMessages: { ...node.textMessages },
lastSeen: node.lastSeen
};
}
private buildFinalizedNodePresentation(
node: MutableDebugNetworkNode,
state: DebugNetworkBuildState,
user: User | undefined,
hasActiveEdge: boolean
): FinalizedNodePresentation {
if (node.kind === 'local-client')
return this.buildLocalNodePresentation(node, state);
return this.buildRemoteNodePresentation(node, user, hasActiveEdge);
}
private buildLocalNodePresentation(
node: MutableDebugNetworkNode,
state: DebugNetworkBuildState
): FinalizedNodePresentation {
return {
userId: node.userId || state.currentUser?.id || null,
label: state.currentUser?.displayName || state.currentUser?.username || node.label || 'You',
secondaryLabel: state.currentUser?.username ? `@${state.currentUser.username}` : 'You',
title: 'Current client',
isActive: true
};
}
private buildRemoteNodePresentation(
node: MutableDebugNetworkNode,
user: User | undefined,
hasActiveEdge: boolean
): FinalizedNodePresentation {
return {
userId: node.userId || user?.id || null,
label: user?.displayName || user?.username || node.label,
secondaryLabel: user?.username ? `@${user.username}` : node.secondaryLabel,
title: node.title,
isActive: hasActiveEdge || node.isActive
};
}
private buildFinalizedNodeStreams(node: MutableDebugNetworkNode): { audio: number; video: number } {
return {
audio: node.streams.audio > 0 ? node.streams.audio : (node.isVoiceConnected ? 1 : 0), audio: node.streams.audio > 0 ? node.streams.audio : (node.isVoiceConnected ? 1 : 0),
video: node.streams.video > 0 ? node.streams.video : (node.isStreaming ? 1 : 0) video: node.streams.video > 0 ? node.streams.video : (node.isStreaming ? 1 : 0)
}; };
const downloads = this.getFreshDownloadStats(node, now); }
private buildFinalizedNodeStatuses(
node: MutableDebugNetworkNode,
streams: { audio: number; video: number },
isTyping: boolean,
hasActiveEdge: boolean
): string[] {
const statuses: string[] = []; const statuses: string[] = [];
if (node.kind === 'local-client' || node.kind === 'remote-client') { if (node.kind === 'local-client' || node.kind === 'remote-client') {
@@ -1018,36 +1279,19 @@ class DebugNetworkSnapshotBuilder {
if (node.connectionDrops > 0) if (node.connectionDrops > 0)
statuses.push(this.buildStreamStatus(node.connectionDrops, 'drop')); statuses.push(this.buildStreamStatus(node.connectionDrops, 'drop'));
} else if (node.kind === 'signaling-server') {
statuses.push(hasActiveEdge ? 'connected' : 'idle'); return statuses;
} else if (hasActiveEdge) {
statuses.push('active');
} }
return { if (node.kind === 'signaling-server') {
id: node.id, statuses.push(hasActiveEdge ? 'connected' : 'idle');
identity: node.identity, return statuses;
userId, }
kind: node.kind,
label, if (hasActiveEdge)
secondaryLabel, statuses.push('active');
title,
statuses, return statuses;
isActive: node.kind === 'local-client' || hasActiveEdge || node.isActive,
isVoiceConnected: node.isVoiceConnected,
isTyping,
isSpeaking: node.isSpeaking,
isMuted: node.isMuted,
isDeafened: node.isDeafened,
isStreaming: node.isStreaming,
connectionDrops: node.connectionDrops,
downloads,
handshake: { ...node.handshake },
pingMs: node.pingMs,
streams,
textMessages: { ...node.textMessages },
lastSeen: node.lastSeen
};
} }
private applyLivePeerMetricsToNetworkNode( private applyLivePeerMetricsToNetworkNode(
@@ -1264,7 +1508,7 @@ class DebugNetworkSnapshotBuilder {
if (value.length <= 12) if (value.length <= 12)
return value; return value;
return `${value.slice(0, 6)}${value.slice(-4)}`; return `${value.slice(0, 6)}...${value.slice(-4)}`;
} }
private getEntryPayloadRecord(payload: unknown): Record<string, unknown> | null { private getEntryPayloadRecord(payload: unknown): Record<string, unknown> | null {

View File

@@ -2,3 +2,4 @@ export * from './notification-audio.service';
export * from '../models/debugging.models'; export * from '../models/debugging.models';
export * from './debugging/debugging.service'; export * from './debugging/debugging.service';
export * from './settings-modal.service'; export * from './settings-modal.service';
export * from './user-status.service';

View File

@@ -41,6 +41,9 @@ export class NotificationAudioService {
/** Reactive notification volume (0 - 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
/** When true, all sound playback is suppressed (Do Not Disturb). */
readonly dndMuted = signal(false);
constructor() { constructor() {
this.preload(); this.preload();
} }
@@ -106,6 +109,9 @@ export class NotificationAudioService {
* the persisted {@link notificationVolume} is used. * the persisted {@link notificationVolume} is used.
*/ */
play(sound: AppSound, volumeOverride?: number): void { play(sound: AppSound, volumeOverride?: number): void {
if (this.dndMuted())
return;
const cached = this.cache.get(sound); const cached = this.cache.get(sound);
const src = this.sources.get(sound); const src = this.sources.get(sound);

View File

@@ -0,0 +1,181 @@
import {
Injectable,
OnDestroy,
NgZone,
inject
} from '@angular/core';
import { Store } from '@ngrx/store';
import { UsersActions } from '../../store/users/users.actions';
import { selectManualStatus, selectCurrentUser } from '../../store/users/users.selectors';
import { RealtimeSessionFacade } from '../realtime';
import { NotificationAudioService } from './notification-audio.service';
import { UserStatus } from '../../shared-kernel';
const BROWSER_IDLE_POLL_MS = 10_000;
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
interface ElectronIdleApi {
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
getIdleState: () => Promise<'active' | 'idle'>;
}
type IdleAwareWindow = Window & {
electronAPI?: ElectronIdleApi;
};
/**
* Orchestrates user status based on idle detection (Electron powerMonitor
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
*
* Manual status always takes priority over automatic idle detection.
* When manual status is cleared, the service falls back to automatic.
*/
@Injectable({ providedIn: 'root' })
export class UserStatusService implements OnDestroy {
private store = inject(Store);
private zone = inject(NgZone);
private webrtc = inject(RealtimeSessionFacade);
private audio = inject(NotificationAudioService);
private readonly manualStatus = this.store.selectSignal(selectManualStatus);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private electronCleanup: (() => void) | null = null;
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
private lastActivityTimestamp = Date.now();
private browserActivityListeners: (() => void)[] = [];
private currentAutoStatus: UserStatus = 'online';
private started = false;
start(): void {
if (this.started)
return;
this.started = true;
if (this.getElectronIdleApi()?.onIdleStateChanged) {
this.startElectronIdleDetection();
} else {
this.startBrowserIdleDetection();
}
}
/** Set a manual status override (e.g. DND = 'busy'). Pass `null` to clear. */
setManualStatus(status: UserStatus | null): void {
this.store.dispatch(UsersActions.setManualStatus({ status }));
this.audio.dndMuted.set(status === 'busy');
this.broadcastStatus(this.resolveEffectiveStatus(status));
}
ngOnDestroy(): void {
this.cleanup();
}
private cleanup(): void {
this.electronCleanup?.();
this.electronCleanup = null;
if (this.browserPollTimer) {
clearInterval(this.browserPollTimer);
this.browserPollTimer = null;
}
for (const remove of this.browserActivityListeners) {
remove();
}
this.browserActivityListeners = [];
this.started = false;
}
private startElectronIdleDetection(): void {
const api = this.getElectronIdleApi();
if (!api)
return;
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
this.zone.run(() => {
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
this.applyAutoStatusIfAllowed();
});
});
// Check initial state
api.getIdleState().then((idleState: 'active' | 'idle') => {
this.zone.run(() => {
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
this.applyAutoStatusIfAllowed();
});
});
}
private startBrowserIdleDetection(): void {
this.lastActivityTimestamp = Date.now();
const onActivity = () => {
this.lastActivityTimestamp = Date.now();
const wasAway = this.currentAutoStatus === 'away';
if (wasAway) {
this.currentAutoStatus = 'online';
this.zone.run(() => this.applyAutoStatusIfAllowed());
}
};
const events = [
'mousemove',
'keydown',
'mousedown',
'touchstart',
'scroll'
] as const;
for (const evt of events) {
document.addEventListener(evt, onActivity, { passive: true });
this.browserActivityListeners.push(() =>
document.removeEventListener(evt, onActivity)
);
}
this.zone.runOutsideAngular(() => {
this.browserPollTimer = setInterval(() => {
const idle = Date.now() - this.lastActivityTimestamp >= BROWSER_IDLE_THRESHOLD_MS;
if (idle && this.currentAutoStatus !== 'away') {
this.currentAutoStatus = 'away';
this.zone.run(() => this.applyAutoStatusIfAllowed());
}
}, BROWSER_IDLE_POLL_MS);
});
}
private applyAutoStatusIfAllowed(): void {
const manualStatus = this.manualStatus();
// Manual status overrides automatic
if (manualStatus)
return;
const currentUser = this.currentUser();
if (currentUser?.status !== this.currentAutoStatus) {
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
this.store.dispatch(UsersActions.updateCurrentUser({ updates: { status: this.currentAutoStatus } }));
this.broadcastStatus(this.currentAutoStatus);
}
}
private resolveEffectiveStatus(manualStatus: UserStatus | null): UserStatus {
return manualStatus ?? this.currentAutoStatus;
}
private broadcastStatus(status: UserStatus): void {
this.webrtc.sendRawMessage({
type: 'status_update',
status
});
}
private getElectronIdleApi(): ElectronIdleApi | undefined {
return (window as IdleAwareWindow).electronAPI;
}
}

View File

@@ -13,6 +13,7 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` | | **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
@@ -28,6 +29,7 @@ The larger domains also keep longer design notes in their own folders:
- [authentication/README.md](authentication/README.md) - [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md) - [notifications/README.md](notifications/README.md)
- [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md) - [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md) - [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md) - [voice-connection/README.md](voice-connection/README.md)

View File

@@ -3,6 +3,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants'; import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model'; import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
import {
arrayBufferToBase64,
decodeBase64,
iterateBlobChunks
} from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService { export class AttachmentTransferTransportService {
@@ -10,14 +15,7 @@ export class AttachmentTransferTransportService {
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
decodeBase64(base64: string): Uint8Array { decodeBase64(base64: string): Uint8Array {
const binary = atob(base64); return decodeBase64(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
} }
async streamFileToPeer( async streamFileToPeer(
@@ -27,31 +25,20 @@ export class AttachmentTransferTransportService {
file: File, file: File,
isCancelled: () => boolean isCancelled: () => boolean
): Promise<void> { ): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) {
let offset = 0;
let chunkIndex = 0;
while (offset < file.size) {
if (isCancelled()) if (isCancelled())
break; break;
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = { const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
fileId, fileId,
index: chunkIndex, index: chunk.index,
total: totalChunks, total: chunk.total,
data: base64 data: chunk.base64
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++;
} }
} }
@@ -67,7 +54,7 @@ export class AttachmentTransferTransportService {
if (!base64Full) if (!base64Full)
return; return;
const fileBytes = this.decodeBase64(base64Full); const fileBytes = decodeBase64(base64Full);
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
@@ -81,7 +68,7 @@ export class AttachmentTransferTransportService {
slice.byteOffset, slice.byteOffset,
slice.byteOffset + slice.byteLength slice.byteOffset + slice.byteLength
); );
const base64Chunk = this.arrayBufferToBase64(sliceBuffer); const base64Chunk = arrayBufferToBase64(sliceBuffer);
const fileChunkEvent: FileChunkEvent = { const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
@@ -94,16 +81,4 @@ export class AttachmentTransferTransportService {
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
} }
} }
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
} }

View File

@@ -1,5 +1,4 @@
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants';
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
/** /**
* EWMA smoothing weight for the previous speed estimate. * EWMA smoothing weight for the previous speed estimate.

View File

@@ -1,35 +1,45 @@
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2"> <div class="w-full border-t border-border bg-card/50 px-1 py-2">
<div class="flex-1"></div>
@if (user()) { @if (user()) {
<div class="flex items-center gap-2 text-sm"> <div class="flex flex-col items-center gap-1 text-xs">
<ng-icon <button
name="lucideUser" #avatarBtn
class="w-4 h-4 text-muted-foreground" type="button"
class="rounded-full transition-opacity hover:opacity-90"
(click)="toggleProfileCard(avatarBtn)"
>
<app-user-avatar
[name]="user()!.displayName"
[avatarUrl]="user()!.avatarUrl"
size="sm"
[status]="user()!.status"
[showStatusBadge]="true"
/> />
<span class="text-foreground">{{ user()?.displayName }}</span> </button>
</div> </div>
} @else { } @else {
<div class="flex flex-col items-center gap-1">
<button <button
type="button" type="button"
(click)="goto('login')" (click)="goto('login')"
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1" class="w-full px-1 py-1 text-[10px] rounded bg-secondary hover:bg-secondary/80 flex items-center justify-center gap-1"
> >
<ng-icon <ng-icon
name="lucideLogIn" name="lucideLogIn"
class="w-4 h-4" class="w-3 h-3"
/> />
Login Login
</button> </button>
<button <button
type="button" type="button"
(click)="goto('register')" (click)="goto('register')"
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1" class="w-full px-1 py-1 text-[10px] rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center justify-center gap-1"
> >
<ng-icon <ng-icon
name="lucideUserPlus" name="lucideUserPlus"
class="w-4 h-4" class="w-3 h-3"
/> />
Register Register
</button> </button>
</div>
} }
</div> </div>

View File

@@ -3,19 +3,21 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
lucideUser,
lucideLogIn,
lucideUserPlus
} from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
import { UserAvatarComponent } from '../../../../shared';
@Component({ @Component({
selector: 'app-user-bar', selector: 'app-user-bar',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon], imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [ viewProviders: [
provideIcons({ lucideUser, provideIcons({
lucideLogIn, lucideLogIn,
lucideUserPlus }) lucideUserPlus })
], ],
@@ -29,6 +31,16 @@ export class UserBarComponent {
user = this.store.selectSignal(selectCurrentUser); user = this.store.selectSignal(selectCurrentUser);
private router = inject(Router); private router = inject(Router);
private profileCard = inject(ProfileCardService);
toggleProfileCard(origin: HTMLElement): void {
const user = this.user();
if (!user)
return;
this.profileCard.open(origin, user, { placement: 'above', editable: true });
}
/** Navigate to the specified authentication page. */ /** Navigate to the specified authentication page. */
goto(path: 'login' | 'register') { goto(path: 'login' | 'register') {

View File

@@ -12,7 +12,7 @@ export class LinkMetadataService {
private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly serverDirectory = inject(ServerDirectoryFacade);
extractUrls(content: string): string[] { extractUrls(content: string): string[] {
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]); return [...content.matchAll(URL_PATTERN)].map((match) => match[0]);
} }
async fetchMetadata(url: string): Promise<LinkMetadata> { async fetchMetadata(url: string): Promise<LinkMetadata> {

View File

@@ -0,0 +1,149 @@
export type SpotifyResourceType = 'album' | 'artist' | 'episode' | 'playlist' | 'show' | 'track';
export type SoundcloudResourceType = 'playlist' | 'track';
export interface SpotifyResource {
type: SpotifyResourceType;
id: string;
}
export interface SoundcloudResource {
canonicalUrl: string;
type: SoundcloudResourceType;
}
const SPOTIFY_RESOURCE_TYPES = new Set<SpotifyResourceType>([
'album',
'artist',
'episode',
'playlist',
'show',
'track'
]);
const SPOTIFY_URI_PATTERN = /^spotify:(album|artist|episode|playlist|show|track):([a-zA-Z0-9]+)$/i;
const SOUNDCLOUD_HOST_PATTERN = /^(?:www\.|m\.)?soundcloud\.com$/i;
const YOUTUBE_HOST_PATTERN = /^(?:www\.|m\.|music\.)?youtube\.com$/i;
const YOUTU_BE_HOST_PATTERN = /^(?:www\.)?youtu\.be$/i;
const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/;
function parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch {
return null;
}
}
export function extractYoutubeVideoId(url: string): string | null {
const parsedUrl = parseUrl(url);
if (!parsedUrl) {
return null;
}
if (YOUTU_BE_HOST_PATTERN.test(parsedUrl.hostname)) {
const shortId = parsedUrl.pathname.split('/').filter(Boolean)[0] ?? '';
return YOUTUBE_VIDEO_ID_PATTERN.test(shortId) ? shortId : null;
}
if (!YOUTUBE_HOST_PATTERN.test(parsedUrl.hostname)) {
return null;
}
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
if (parsedUrl.pathname === '/watch') {
const queryId = parsedUrl.searchParams.get('v') ?? '';
return YOUTUBE_VIDEO_ID_PATTERN.test(queryId) ? queryId : null;
}
if (pathSegments.length >= 2 && (pathSegments[0] === 'embed' || pathSegments[0] === 'shorts')) {
const pathId = pathSegments[1];
return YOUTUBE_VIDEO_ID_PATTERN.test(pathId) ? pathId : null;
}
return null;
}
export function isYoutubeUrl(url?: string): boolean {
return !!url && extractYoutubeVideoId(url) !== null;
}
export function extractSpotifyResource(url: string): SpotifyResource | null {
const spotifyUriMatch = url.match(SPOTIFY_URI_PATTERN);
if (spotifyUriMatch?.[1] && spotifyUriMatch[2]) {
return {
type: spotifyUriMatch[1].toLowerCase() as SpotifyResourceType,
id: spotifyUriMatch[2]
};
}
const parsedUrl = parseUrl(url);
if (!parsedUrl || !/^(?:open|play)\.spotify\.com$/i.test(parsedUrl.hostname)) {
return null;
}
const segments = parsedUrl.pathname.split('/').filter(Boolean);
if (segments.length >= 2 && SPOTIFY_RESOURCE_TYPES.has(segments[0] as SpotifyResourceType)) {
return {
type: segments[0] as SpotifyResourceType,
id: segments[1]
};
}
if (segments.length >= 4 && segments[0] === 'user' && segments[2] === 'playlist') {
return {
type: 'playlist',
id: segments[3]
};
}
return null;
}
export function isSpotifyUrl(url?: string): boolean {
return !!url && extractSpotifyResource(url) !== null;
}
export function extractSoundcloudResource(url: string): SoundcloudResource | null {
const parsedUrl = parseUrl(url);
if (!parsedUrl || !SOUNDCLOUD_HOST_PATTERN.test(parsedUrl.hostname)) {
return null;
}
const segments = parsedUrl.pathname.split('/').filter(Boolean);
if (segments.length === 2) {
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/${segments[1]}`);
return {
canonicalUrl: canonicalUrl.toString(),
type: 'track'
};
}
if (segments.length === 3 && segments[1] === 'sets') {
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/sets/${segments[2]}`);
return {
canonicalUrl: canonicalUrl.toString(),
type: 'playlist'
};
}
return null;
}
export function isSoundcloudUrl(url?: string): boolean {
return !!url && extractSoundcloudResource(url) !== null;
}
export function hasDedicatedChatEmbed(url?: string): boolean {
return isYoutubeUrl(url) || isSpotifyUrl(url) || isSoundcloudUrl(url);
}

View File

@@ -5,7 +5,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide'; import { lucideX } from '@ng-icons/lucide';
import { LinkMetadata } from '../../../../../../shared-kernel'; import { LinkMetadata } from '../../../../../../../shared-kernel';
@Component({ @Component({
selector: 'app-chat-link-embed', selector: 'app-chat-link-embed',

View File

@@ -6,11 +6,16 @@
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30" class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted" [class.opacity-50]="msg.isDeleted"
> >
<div
class="flex-shrink-0 cursor-pointer"
(click)="openSenderProfileCard($event); $event.stopPropagation()"
>
<app-user-avatar <app-user-avatar
[name]="msg.senderName" [name]="senderUser().displayName || msg.senderName"
[avatarUrl]="senderUser().avatarUrl"
size="md" size="md"
class="flex-shrink-0"
/> />
</div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@if (msg.replyToId) { @if (msg.replyToId) {
@@ -34,7 +39,11 @@
} }
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ msg.senderName }}</span> <span
class="font-semibold text-foreground cursor-pointer hover:underline"
(click)="openSenderProfileCard($event); $event.stopPropagation()"
>{{ msg.senderName }}</span
>
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span> <span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt && !msg.isDeleted) { @if (msg.editedAt && !msg.isDeleted) {
<span class="text-xs text-muted-foreground">(edited)</span> <span class="text-xs text-muted-foreground">(edited)</span>
@@ -91,6 +100,7 @@
@if (msg.linkMetadata?.length) { @if (msg.linkMetadata?.length) {
@for (meta of msg.linkMetadata; track meta.url) { @for (meta of msg.linkMetadata; track meta.url) {
@if (shouldShowLinkEmbed(meta.url)) {
<app-chat-link-embed <app-chat-link-embed
[metadata]="meta" [metadata]="meta"
[canRemove]="isOwnMessage() || isAdmin()" [canRemove]="isOwnMessage() || isAdmin()"
@@ -98,6 +108,7 @@
/> />
} }
} }
}
@if (attachmentsList.length > 0) { @if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2"> <div class="mt-2 space-y-2">
@@ -179,7 +190,7 @@
[class.text-destructive]="!!att.requestError" [class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError" [class.text-muted-foreground]="!att.requestError"
> >
{{ att.requestError || 'Waiting for image source' }} {{ att.requestError || 'Waiting for image source...' }}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ import {
signal, signal,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideCheck, lucideCheck,
@@ -30,14 +31,21 @@ import {
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment'; } from '../../../../../attachment';
import { KlipyService } from '../../../../application/services/klipy.service'; import { KlipyService } from '../../../../application/services/klipy.service';
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel'; import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
import {
DELETED_MESSAGE_CONTENT,
Message,
User
} from '../../../../../../shared-kernel';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
ProfileCardService,
UserAvatarComponent UserAvatarComponent
} from '../../../../../../shared'; } from '../../../../../../shared';
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component'; import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
import { ChatLinkEmbedComponent } from './chat-link-embed.component'; import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.component';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,
@@ -114,12 +122,14 @@ export class ChatMessageItemComponent {
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly profileCard = inject(ProfileCardService);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
readonly message = input.required<Message>(); readonly message = input.required<Message>();
readonly repliedMessage = input<Message | undefined>(); readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null); readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false); readonly isAdmin = input(false);
readonly userLookup = input<ReadonlyMap<string, User>>(new Map());
readonly replyRequested = output<ChatMessageReplyEvent>(); readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>(); readonly deleteRequested = output<ChatMessageDeleteEvent>();
@@ -136,9 +146,32 @@ export class ChatMessageItemComponent {
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT; readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly isEditing = signal(false); readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => {
const msg = this.message();
const found = this.userLookup().get(msg.senderId);
return found ?? {
id: msg.senderId,
oderId: msg.senderId,
username: msg.senderName,
displayName: msg.senderName,
status: 'disconnected',
role: 'member',
joinedAt: 0
};
});
editContent = ''; editContent = '';
openSenderProfileCard(event: MouseEvent): void {
event.stopPropagation();
const el = event.currentTarget as HTMLElement;
const user = this.senderUser();
const editable = user.id === this.currentUserId();
this.profileCard.open(el, user, { editable });
}
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => { readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion(); void this.attachmentVersion();
@@ -246,6 +279,10 @@ export class ChatMessageItemComponent {
}); });
} }
shouldShowLinkEmbed(url?: string): boolean {
return !hasDedicatedChatEmbed(url);
}
requestReferenceScroll(messageId: string): void { requestReferenceScroll(messageId: string): void {
this.referenceRequested.emit(messageId); this.referenceRequested.emit(messageId);
} }
@@ -382,8 +419,8 @@ export class ChatMessageItemComponent {
} }
return this.isVideoAttachment(attachment) return this.isVideoAttachment(attachment)
? 'Waiting for video source' ? 'Waiting for video source...'
: 'Waiting for audio source'; : 'Waiting for audio source...';
} }
getMediaAttachmentActionLabel(attachment: Attachment): string { getMediaAttachmentActionLabel(attachment: Attachment): string {
@@ -465,8 +502,8 @@ export class ChatMessageItemComponent {
? 'Large video. Accept the download to watch it in chat.' ? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.' : 'Large audio file. Accept the download to play it in chat.'
: isVideo : isVideo
? 'Waiting for video source' ? 'Waiting for video source...'
: 'Waiting for audio source', : 'Waiting for audio source...',
progressPercent: attachment.size > 0 progressPercent: attachment.size > 0
? ((attachment.receivedBytes || 0) * 100) / attachment.size ? ((attachment.receivedBytes || 0) * 100) / attachment.size
: 0 : 0

View File

@@ -45,6 +45,14 @@
<div class="block"> <div class="block">
<app-chat-youtube-embed [url]="node.url" /> <app-chat-youtube-embed [url]="node.url" />
</div> </div>
} @else if (isSpotifyUrl(node.url)) {
<div class="block">
<app-chat-spotify-embed [url]="node.url" />
</div>
} @else if (isSoundcloudUrl(node.url)) {
<div class="block">
<app-chat-soundcloud-embed [url]="node.url" />
</div>
} }
</ng-template> </ng-template>
</remark> </remark>

View File

@@ -5,8 +5,15 @@ import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; import {
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component'; isSoundcloudUrl,
isSpotifyUrl,
isYoutubeUrl
} from '../../../../../domain/rules/link-embed.rules';
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
import { ChatSoundcloudEmbedComponent } from '../chat-soundcloud-embed/chat-soundcloud-embed.component';
import { ChatSpotifyEmbedComponent } from '../chat-spotify-embed/chat-spotify-embed.component';
import { ChatYoutubeEmbedComponent } from '../chat-youtube-embed/chat-youtube-embed.component';
const PRISM_LANGUAGE_ALIASES: Record<string, string> = { const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp', cs: 'csharp',
@@ -40,6 +47,8 @@ const REMARK_PROCESSOR = unified()
RemarkModule, RemarkModule,
MermaidComponent, MermaidComponent,
ChatImageProxyFallbackDirective, ChatImageProxyFallbackDirective,
ChatSpotifyEmbedComponent,
ChatSoundcloudEmbedComponent,
ChatYoutubeEmbedComponent ChatYoutubeEmbedComponent
], ],
templateUrl: './chat-message-markdown.component.html' templateUrl: './chat-message-markdown.component.html'
@@ -63,6 +72,14 @@ export class ChatMessageMarkdownComponent {
return isYoutubeUrl(url); return isYoutubeUrl(url);
} }
isSpotifyUrl(url?: string): boolean {
return isSpotifyUrl(url);
}
isSoundcloudUrl(url?: string): boolean {
return isSoundcloudUrl(url);
}
isMermaidCodeBlock(lang?: string): boolean { isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid'; return this.normalizeCodeLanguage(lang) === 'mermaid';
} }

View File

@@ -0,0 +1,11 @@
@if (embedUrl(); as soundcloudEmbedUrl) {
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<iframe
[src]="soundcloudEmbedUrl"
[style.height.px]="embedHeight()"
class="w-full border-0"
loading="lazy"
title="SoundCloud player"
></iframe>
</div>
}

View File

@@ -0,0 +1,44 @@
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { extractSoundcloudResource } from '../../../../../domain/rules/link-embed.rules';
@Component({
selector: 'app-chat-soundcloud-embed',
standalone: true,
templateUrl: './chat-soundcloud-embed.component.html'
})
export class ChatSoundcloudEmbedComponent {
readonly url = input.required<string>();
readonly resource = computed(() => extractSoundcloudResource(this.url()));
readonly embedHeight = computed(() => this.resource()?.type === 'playlist' ? 352 : 166);
readonly embedUrl = computed(() => {
const resource = this.resource();
if (!resource) {
return '';
}
const embedUrl = new URL('https://w.soundcloud.com/player/');
embedUrl.searchParams.set('url', resource.canonicalUrl);
embedUrl.searchParams.set('auto_play', 'false');
embedUrl.searchParams.set('hide_related', 'false');
embedUrl.searchParams.set('show_comments', 'false');
embedUrl.searchParams.set('show_user', 'true');
embedUrl.searchParams.set('show_reposts', 'false');
embedUrl.searchParams.set('show_teaser', 'true');
embedUrl.searchParams.set('visual', resource.type === 'playlist' ? 'true' : 'false');
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
});
private readonly sanitizer = inject(DomSanitizer);
}

View File

@@ -0,0 +1,12 @@
@if (embedUrl(); as spotifyEmbedUrl) {
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<iframe
[src]="spotifyEmbedUrl"
[style.height.px]="embedHeight()"
class="w-full border-0"
loading="lazy"
title="Spotify player"
allowfullscreen
></iframe>
</div>
}

View File

@@ -0,0 +1,51 @@
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { extractSpotifyResource } from '../../../../../domain/rules/link-embed.rules';
@Component({
selector: 'app-chat-spotify-embed',
standalone: true,
templateUrl: './chat-spotify-embed.component.html'
})
export class ChatSpotifyEmbedComponent {
readonly url = input.required<string>();
readonly resource = computed(() => extractSpotifyResource(this.url()));
readonly embedHeight = computed(() => {
const resource = this.resource();
if (!resource) {
return 0;
}
switch (resource.type) {
case 'track':
case 'episode':
return 152;
default:
return 352;
}
});
readonly embedUrl = computed(() => {
const resource = this.resource();
if (!resource) {
return '';
}
const embedUrl = new URL(`https://open.spotify.com/embed/${resource.type}/${encodeURIComponent(resource.id)}`);
embedUrl.searchParams.set('utm_source', 'generator');
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
});
private readonly sanitizer = inject(DomSanitizer);
}

View File

@@ -1,48 +0,0 @@
import { Component, computed, input } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
@Component({
selector: 'app-chat-youtube-embed',
standalone: true,
template: `
@if (videoId()) {
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
<iframe
[src]="embedUrl()"
class="aspect-video w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
></iframe>
</div>
}
`
})
export class ChatYoutubeEmbedComponent {
readonly url = input.required<string>();
readonly videoId = computed(() => {
const match = this.url().match(YOUTUBE_URL_PATTERN);
return match?.[1] ?? null;
});
readonly embedUrl = computed(() => {
const id = this.videoId();
if (!id)
return '';
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
);
});
constructor(private readonly sanitizer: DomSanitizer) {}
}
export function isYoutubeUrl(url?: string): boolean {
return !!url && YOUTUBE_URL_PATTERN.test(url);
}

View File

@@ -0,0 +1,10 @@
@if (videoId()) {
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
<iframe
[src]="embedUrl()"
class="aspect-video w-full"
allowfullscreen
loading="lazy"
></iframe>
</div>
}

View File

@@ -0,0 +1,52 @@
import {
Component,
computed,
inject,
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app';
function resolveYoutubeClientOrigin(): string {
if (typeof window === 'undefined') {
return YOUTUBE_EMBED_FALLBACK_ORIGIN;
}
const origin = window.location.origin;
return /^https?:\/\//.test(origin)
? origin
: YOUTUBE_EMBED_FALLBACK_ORIGIN;
}
@Component({
selector: 'app-chat-youtube-embed',
standalone: true,
templateUrl: './chat-youtube-embed.component.html'
})
export class ChatYoutubeEmbedComponent {
readonly url = input.required<string>();
readonly videoId = computed(() => extractYoutubeVideoId(this.url()));
readonly embedUrl = computed(() => {
const id = this.videoId();
if (!id)
return '';
const clientOrigin = resolveYoutubeClientOrigin();
const embedUrl = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`);
embedUrl.searchParams.set('origin', clientOrigin);
embedUrl.searchParams.set('widget_referrer', clientOrigin);
return this.sanitizer.bypassSecurityTrustResourceUrl(
embedUrl.toString()
);
});
private readonly sanitizer = inject(DomSanitizer);
}

View File

@@ -7,7 +7,7 @@
@if (syncing() && !loading()) { @if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground"> <div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div> <div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Syncing messages</span> <span>Syncing messages...</span>
</div> </div>
} }
@@ -53,6 +53,7 @@
[repliedMessage]="findRepliedMessage(message.replyToId)" [repliedMessage]="findRepliedMessage(message.replyToId)"
[currentUserId]="currentUserId()" [currentUserId]="currentUserId()"
[isAdmin]="isAdmin()" [isAdmin]="isAdmin()"
[userLookup]="userLookup()"
(replyRequested)="handleReplyRequested($event)" (replyRequested)="handleReplyRequested($event)"
(deleteRequested)="handleDeleteRequested($event)" (deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)" (editSaved)="handleEditSaved($event)"

View File

@@ -8,13 +8,15 @@ import {
ViewChild, ViewChild,
computed, computed,
effect, effect,
inject,
input, input,
output, output,
signal signal
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store';
import { Attachment } from '../../../../../attachment'; import { Attachment } from '../../../../../attachment';
import { getMessageTimestamp } from '../../../../domain/rules/message.rules'; import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
import { Message } from '../../../../../../shared-kernel'; import { Message, User } from '../../../../../../shared-kernel';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,
@@ -23,6 +25,7 @@ import {
ChatMessageReactionEvent, ChatMessageReactionEvent,
ChatMessageReplyEvent ChatMessageReplyEvent
} from '../../models/chat-messages.model'; } from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component'; import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal { interface PrismGlobal {
@@ -47,6 +50,8 @@ declare global {
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>; @ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
private readonly store = inject(Store);
private readonly allUsers = this.store.selectSignal(selectAllUsers);
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', { private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
@@ -110,6 +115,20 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return labels; return labels;
}); });
readonly userLookup = computed<ReadonlyMap<string, User>>(() => {
const lookup = new Map<string, User>();
for (const user of this.allUsers()) {
lookup.set(user.id, user);
if (user.oderId && user.oderId !== user.id) {
lookup.set(user.oderId, user);
}
}
return lookup;
});
private initialScrollObserver: MutationObserver | null = null; private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null; private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null; private boundOnImageLoad: (() => void) | null = null;

View File

@@ -62,7 +62,7 @@
@if (loading() && results().length === 0) { @if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground"> <div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span> <span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY</p> <p class="text-sm">Loading GIFs from KLIPY...</p>
</div> </div>
} @else if (results().length === 0) { } @else if (results().length === 0) {
<div <div
@@ -125,7 +125,7 @@
[disabled]="loading()" [disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
> >
{{ loading() ? 'Loading' : 'Load more' }} {{ loading() ? 'Loading...' : 'Load more' }}
</button> </button>
} }
</div> </div>

View File

@@ -27,17 +27,14 @@
role="button" role="button"
tabindex="0" tabindex="0"
> >
<!-- Avatar with online indicator --> <!-- Avatar with status indicator -->
<div class="relative"> <div class="relative">
<app-user-avatar <app-user-avatar
[name]="user.displayName" [name]="user.displayName"
[status]="user.status"
[showStatusBadge]="true"
size="sm" size="sm"
/> />
<span
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div> </div>
<!-- User Info --> <!-- User Info -->
@@ -59,6 +56,16 @@
/> />
} }
</div> </div>
@if (user.status && user.status !== 'online') {
<span
class="text-xs"
[class.text-yellow-500]="user.status === 'away'"
[class.text-red-500]="user.status === 'busy'"
[class.text-muted-foreground]="user.status === 'offline'"
>
{{ user.status === 'busy' ? 'Do Not Disturb' : (user.status | titlecase) }}
</span>
}
</div> </div>
<!-- Voice/Screen Status --> <!-- Voice/Screen Status -->

View File

@@ -83,7 +83,7 @@ export function shouldDeliverNotification(
return false; return false;
} }
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') { if (context.currentUser?.status === 'busy') {
return false; return false;
} }
@@ -151,7 +151,7 @@ function formatMessagePreview(senderName: string, content: string): string {
} }
const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT
? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}` ? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}...`
: normalisedContent; : normalisedContent;
return `${senderName}: ${preview}`; return `${senderName}: ${preview}`;

View File

@@ -13,9 +13,9 @@ import {
lucideMessageSquareText, lucideMessageSquareText,
lucideMoonStar lucideMoonStar
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors';
import type { Room } from '../../../../shared-kernel'; import type { Room } from '../../../../../shared-kernel';
import { NotificationsFacade } from '../../application/facades/notifications.facade'; import { NotificationsFacade } from '../../../application/facades/notifications.facade';
@Component({ @Component({
selector: 'app-notifications-settings', selector: 'app-notifications-settings',

View File

@@ -1,3 +1,3 @@
export * from './application/facades/notifications.facade'; export * from './application/facades/notifications.facade';
export * from './application/effects/notifications.effects'; export * from './application/effects/notifications.effects';
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component'; export { NotificationsSettingsComponent } from './feature/settings/notifications-settings/notifications-settings.component';

View File

@@ -0,0 +1,48 @@
# Profile Avatar Domain
Owns local profile picture workflow plus peer-synced profile-card metadata: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar/profile sync metadata.
## Responsibilities
- Accept `.webp`, `.gif`, `.jpg`, `.jpeg` profile image sources.
- Let user drag and zoom source inside fixed preview frame before saving.
- Render static avatars to `256x256` WebP with client-side compression.
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
- Let the local user edit their profile-card display name and description.
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
- Reuse the avatar summary/request/full handshake to sync profile text (`displayName`, `description`, `profileUpdatedAt`) alongside avatar state.
## Module map
```mermaid
graph TD
PC[ProfileCardComponent] --> PAE[ProfileAvatarEditorComponent]
PAE --> PAF[ProfileAvatarFacade]
PAF --> PAI[ProfileAvatarImageService]
PAF --> PAS[ProfileAvatarStorageService]
PAF --> Store[UsersActions.updateCurrentUserAvatar]
Store --> UAV[UserAvatarEffects]
UAV --> RTC[WebRTC data channel]
UAV --> DB[DatabaseService]
click PAE "feature/profile-avatar-editor/" "Crop and zoom editor UI" _blank
click PAF "application/services/profile-avatar.facade.ts" "Facade used by UI and effects" _blank
click PAI "infrastructure/services/profile-avatar-image.service.ts" "Canvas render and compression" _blank
click PAS "infrastructure/services/profile-avatar-storage.service.ts" "Electron file persistence" _blank
```
## Flow
1. `ProfileCardComponent` opens file picker from editable avatar button.
2. `ProfileCardComponent` saves display-name and description edits through the users store.
3. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
4. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
5. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
6. `UserAvatarEffects` broadcasts avatar/profile summaries, answers requests, streams chunks when needed, and persists received profile state locally.
## Notes
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.
- Profile text uses its own `profileUpdatedAt` version so display-name and description changes can sync without replacing a newer avatar.

View File

@@ -0,0 +1,69 @@
import { Injectable, inject } from '@angular/core';
import { User } from '../../../../shared-kernel';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
ProfileAvatarTransform,
ProfileAvatarUpdates
} from '../../domain/profile-avatar.models';
import { ProfileAvatarImageService } from '../../infrastructure/services/profile-avatar-image.service';
import { ProfileAvatarStorageService } from '../../infrastructure/services/profile-avatar-storage.service';
@Injectable({ providedIn: 'root' })
export class ProfileAvatarFacade {
private readonly image = inject(ProfileAvatarImageService);
private readonly storage = inject(ProfileAvatarStorageService);
validateFile(file: File): string | null {
return this.image.validateFile(file);
}
prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
return this.image.prepareEditableSource(file);
}
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
this.image.releaseEditableSource(source);
}
processEditableSource(
source: EditableProfileAvatarSource,
transform: ProfileAvatarTransform
): Promise<ProcessedProfileAvatar> {
return this.image.processEditableSource(source, transform);
}
persistProcessedAvatar(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatar: ProcessedProfileAvatar
): Promise<void> {
return this.storage.persistProcessedAvatar(user, avatar);
}
persistAvatarDataUrl(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatarUrl: string | null | undefined
): Promise<void> {
const mimeMatch = avatarUrl?.match(/^data:([^;]+);base64,/i);
const base64 = avatarUrl?.split(',', 2)[1] ?? '';
const avatarMime = mimeMatch?.[1]?.toLowerCase() ?? 'image/webp';
if (!base64) {
return Promise.resolve();
}
return this.storage.persistProcessedAvatar(user, {
base64,
avatarMime
});
}
buildAvatarUpdates(avatar: ProcessedProfileAvatar): ProfileAvatarUpdates {
return {
avatarUrl: avatar.avatarUrl,
avatarHash: avatar.avatarHash,
avatarMime: avatar.avatarMime,
avatarUpdatedAt: avatar.avatarUpdatedAt
};
}
}

View File

@@ -0,0 +1,38 @@
import {
PROFILE_AVATAR_MAX_ZOOM,
PROFILE_AVATAR_MIN_ZOOM,
clampProfileAvatarTransform,
clampProfileAvatarZoom,
resolveProfileAvatarStorageFileName,
resolveProfileAvatarBaseScale
} from './profile-avatar.models';
describe('profile-avatar models', () => {
it('clamps zoom inside allowed range', () => {
expect(clampProfileAvatarZoom(0.1)).toBe(PROFILE_AVATAR_MIN_ZOOM);
expect(clampProfileAvatarZoom(9)).toBe(PROFILE_AVATAR_MAX_ZOOM);
expect(clampProfileAvatarZoom(2.5)).toBe(2.5);
});
it('resolves cover scale for portrait images', () => {
expect(resolveProfileAvatarBaseScale({ width: 200, height: 400 }, 224)).toBeCloseTo(1.12);
});
it('clamps transform offsets so image still covers crop frame', () => {
const transform = clampProfileAvatarTransform(
{ width: 320, height: 240 },
{ zoom: 1, offsetX: 500, offsetY: -500 },
224
);
expect(transform.offsetX).toBeCloseTo(37.333333, 4);
expect(transform.offsetY).toBe(0);
});
it('maps avatar mime types to storage file names', () => {
expect(resolveProfileAvatarStorageFileName('image/gif')).toBe('profile.gif');
expect(resolveProfileAvatarStorageFileName('image/jpeg')).toBe('profile.jpg');
expect(resolveProfileAvatarStorageFileName('image/webp')).toBe('profile.webp');
expect(resolveProfileAvatarStorageFileName(undefined)).toBe('profile.webp');
});
});

View File

@@ -0,0 +1,99 @@
export const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
'image/webp',
'image/gif',
'image/jpeg'
] as const;
export const PROFILE_AVATAR_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg';
export const PROFILE_AVATAR_OUTPUT_SIZE = 256;
export const PROFILE_AVATAR_EDITOR_FRAME_SIZE = 224;
export const PROFILE_AVATAR_MIN_ZOOM = 1;
export const PROFILE_AVATAR_MAX_ZOOM = 4;
export interface ProfileAvatarDimensions {
width: number;
height: number;
}
export interface EditableProfileAvatarSource extends ProfileAvatarDimensions {
file: File;
objectUrl: string;
mime: string;
name: string;
preservesAnimation: boolean;
}
export interface ProfileAvatarTransform {
zoom: number;
offsetX: number;
offsetY: number;
}
export interface ProfileAvatarUpdates {
avatarUrl: string;
avatarHash: string;
avatarMime: string;
avatarUpdatedAt: number;
}
export interface ProcessedProfileAvatar extends ProfileAvatarUpdates, ProfileAvatarDimensions {
base64: string;
blob: Blob;
}
export function resolveProfileAvatarStorageFileName(mime: string | null | undefined): string {
switch (mime?.toLowerCase()) {
case 'image/gif':
return 'profile.gif';
case 'image/jpeg':
case 'image/jpg':
return 'profile.jpg';
default:
return 'profile.webp';
}
}
export function clampProfileAvatarZoom(zoom: number): number {
if (!Number.isFinite(zoom)) {
return PROFILE_AVATAR_MIN_ZOOM;
}
return Math.min(Math.max(zoom, PROFILE_AVATAR_MIN_ZOOM), PROFILE_AVATAR_MAX_ZOOM);
}
export function resolveProfileAvatarBaseScale(
source: ProfileAvatarDimensions,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): number {
return Math.max(frameSize / source.width, frameSize / source.height);
}
export function clampProfileAvatarTransform(
source: ProfileAvatarDimensions,
transform: ProfileAvatarTransform,
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
): ProfileAvatarTransform {
const zoom = clampProfileAvatarZoom(transform.zoom);
const renderedWidth = source.width * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const renderedHeight = source.height * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
const maxOffsetX = Math.max(0, (renderedWidth - frameSize) / 2);
const maxOffsetY = Math.max(0, (renderedHeight - frameSize) / 2);
return {
zoom,
offsetX: clampOffset(transform.offsetX, maxOffsetX),
offsetY: clampOffset(transform.offsetY, maxOffsetY)
};
}
function clampOffset(value: number, maxMagnitude: number): number {
if (!Number.isFinite(value)) {
return 0;
}
const nextValue = Math.min(Math.max(value, -maxMagnitude), maxMagnitude);
return Object.is(nextValue, -0) ? 0 : nextValue;
}

View File

@@ -0,0 +1,152 @@
<div
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close profile image editor"
></div>
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="border-b border-border p-5">
<h3 class="text-lg font-semibold text-foreground">Adjust profile picture</h3>
<p class="mt-1 text-sm text-muted-foreground">
@if (preservesAnimation()) {
Animated GIF and WebP avatars keep their original animation and framing.
} @else {
Drag image to frame subject. Zoom until preview looks right. Final image saves as 256x256 WebP.
}
</p>
</div>
<div class="grid gap-6 overflow-y-auto p-5 sm:grid-cols-[minmax(0,1fr)_280px]">
<div class="flex flex-col items-center gap-4">
<div
class="relative overflow-hidden rounded-[32px] border border-border bg-secondary/40 shadow-inner touch-none"
[style.width.px]="frameSize"
[style.height.px]="frameSize"
(pointerdown)="onPointerDown($event)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerUp($event)"
(wheel)="onWheel($event)"
>
<img
[src]="source().objectUrl"
[alt]="source().name"
class="pointer-events-none absolute left-1/2 top-1/2 max-w-none select-none"
[style.transform]="imageTransform()"
draggable="false"
/>
<div class="pointer-events-none absolute inset-0 rounded-[32px] ring-1 ring-white/10"></div>
<div class="pointer-events-none absolute inset-4 rounded-full border border-white/45 shadow-[0_0_0_999px_rgba(4,8,15,0.58)]"></div>
</div>
<p class="text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animation and original framing are preserved.
} @else {
Preview matches saved crop.
}
</p>
</div>
<div class="space-y-5">
<div class="rounded-xl border border-border bg-secondary/20 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">Source</p>
<p class="mt-2 truncate text-sm font-medium text-foreground">{{ source().name }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ source().width }} x {{ source().height }}</p>
</div>
<div
class="rounded-xl border border-border bg-secondary/20 p-4"
[class.opacity-60]="preservesAnimation()"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">Zoom</p>
<p class="text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animated avatars keep the original frame sequence.
} @else {
Use wheel or slider.
}
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
(click)="zoomBy(-0.12)"
[disabled]="preservesAnimation()"
>
-
</button>
<button
type="button"
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
(click)="zoomBy(0.12)"
[disabled]="preservesAnimation()"
>
+
</button>
</div>
</div>
<input
type="range"
min="1"
max="4"
step="0.01"
class="mt-4 w-full accent-primary"
[value]="clampedTransform().zoom"
(input)="onZoomInput($event)"
[disabled]="preservesAnimation()"
/>
<p class="mt-2 text-xs text-muted-foreground">
@if (preservesAnimation()) {
Animated upload detected.
} @else {
{{ (clampedTransform().zoom * 100).toFixed(0) }}% zoom
}
</p>
</div>
@if (errorMessage()) {
<div class="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{{ errorMessage() }}
</div>
}
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-border p-4">
<button
type="button"
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancelled.emit(undefined)"
[disabled]="processing()"
>
Cancel
</button>
<button
type="button"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
(click)="confirm()"
[disabled]="processing()"
>
{{ processing() ? 'Saving...' : 'Apply picture' }}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
import {
Component,
HostListener,
computed,
inject,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
ProfileAvatarTransform,
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
clampProfileAvatarTransform,
resolveProfileAvatarBaseScale
} from '../../domain/profile-avatar.models';
@Component({
selector: 'app-profile-avatar-editor',
standalone: true,
imports: [CommonModule],
templateUrl: './profile-avatar-editor.component.html'
})
export class ProfileAvatarEditorComponent {
readonly source = input.required<EditableProfileAvatarSource>();
readonly cancelled = output<undefined>();
readonly confirmed = output<ProcessedProfileAvatar>();
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
readonly processing = signal(false);
readonly errorMessage = signal<string | null>(null);
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
offsetX: 0,
offsetY: 0 });
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
readonly imageTransform = computed(() => {
const source = this.source();
const transform = this.clampedTransform();
const scale = resolveProfileAvatarBaseScale(source, this.frameSize) * transform.zoom;
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
});
private readonly avatar = inject(ProfileAvatarFacade);
private dragPointerId: number | null = null;
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
@HostListener('document:keydown.escape')
onEscape(): void {
if (!this.processing()) {
this.cancelled.emit(undefined);
}
}
onZoomChange(value: string): void {
if (this.preservesAnimation()) {
return;
}
const zoom = Number(value);
this.transform.update((current) => ({
...current,
zoom
}));
}
onZoomInput(event: Event): void {
this.onZoomChange((event.target as HTMLInputElement).value);
}
zoomBy(delta: number): void {
if (this.preservesAnimation()) {
return;
}
this.transform.update((current) => ({
...current,
zoom: current.zoom + delta
}));
}
onWheel(event: WheelEvent): void {
if (this.preservesAnimation()) {
return;
}
event.preventDefault();
this.zoomBy(event.deltaY < 0 ? 0.08 : -0.08);
}
onPointerDown(event: PointerEvent): void {
if (this.processing() || this.preservesAnimation()) {
return;
}
const currentTarget = event.currentTarget as HTMLElement | null;
const currentTransform = this.clampedTransform();
currentTarget?.setPointerCapture(event.pointerId);
this.dragPointerId = event.pointerId;
this.dragOrigin = {
x: event.clientX,
y: event.clientY,
offsetX: currentTransform.offsetX,
offsetY: currentTransform.offsetY
};
}
onPointerMove(event: PointerEvent): void {
if (this.dragPointerId !== event.pointerId || !this.dragOrigin || this.processing() || this.preservesAnimation()) {
return;
}
this.transform.set(clampProfileAvatarTransform(this.source(), {
zoom: this.clampedTransform().zoom,
offsetX: this.dragOrigin.offsetX + (event.clientX - this.dragOrigin.x),
offsetY: this.dragOrigin.offsetY + (event.clientY - this.dragOrigin.y)
}));
}
onPointerUp(event: PointerEvent): void {
if (this.dragPointerId !== event.pointerId) {
return;
}
const currentTarget = event.currentTarget as HTMLElement | null;
currentTarget?.releasePointerCapture(event.pointerId);
this.dragPointerId = null;
this.dragOrigin = null;
}
async confirm(): Promise<void> {
if (this.processing()) {
return;
}
this.processing.set(true);
this.errorMessage.set(null);
try {
const avatar = await this.avatar.processEditableSource(this.source(), this.clampedTransform());
this.confirmed.emit(avatar);
} catch {
this.errorMessage.set('Failed to process profile image.');
} finally {
this.processing.set(false);
}
}
}

View File

@@ -0,0 +1,85 @@
import { Injectable, inject } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { EditableProfileAvatarSource, ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
@Injectable({ providedIn: 'root' })
export class ProfileAvatarEditorService {
private readonly overlay = inject(Overlay);
private overlayRef: OverlayRef | null = null;
open(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar | null> {
this.close();
this.syncThemeVars();
const overlayRef = this.overlay.create({
disposeOnNavigation: true,
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
positionStrategy: this.overlay.position().global()
.centerHorizontally()
.centerVertically(),
scrollStrategy: this.overlay.scrollStrategies.block()
});
this.overlayRef = overlayRef;
const componentRef = overlayRef.attach(new ComponentPortal(ProfileAvatarEditorComponent));
componentRef.setInput('source', source);
return new Promise((resolve) => {
let settled = false;
const finish = (result: ProcessedProfileAvatar | null): void => {
if (settled) {
return;
}
settled = true;
cancelSub.unsubscribe();
confirmSub.unsubscribe();
detachSub.unsubscribe();
if (this.overlayRef === overlayRef) {
this.overlayRef = null;
}
overlayRef.dispose();
resolve(result);
};
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
});
}
close(): void {
if (!this.overlayRef) {
return;
}
const overlayRef = this.overlayRef;
this.overlayRef = null;
overlayRef.dispose();
}
private syncThemeVars(): void {
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
if (!appRoot || !container) {
return;
}
for (const prop of Array.from(appRoot.style)) {
if (prop.startsWith('--')) {
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
}
}
}
}

View File

@@ -0,0 +1,7 @@
export * from './domain/profile-avatar.models';
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
export {
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
ProfileAvatarEditorService
} from './feature/profile-avatar-editor/profile-avatar-editor.service';

View File

@@ -0,0 +1,49 @@
/* eslint-disable @stylistic/js/array-element-newline */
import { isAnimatedGif, isAnimatedWebp } from './profile-avatar-image.service';
describe('profile-avatar image animation detection', () => {
it('detects animated gifs with multiple frames', () => {
const animatedGif = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x3B
]).buffer;
expect(isAnimatedGif(animatedGif)).toBe(true);
});
it('does not mark single-frame gifs as animated', () => {
const staticGif = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
0x3B
]).buffer;
expect(isAnimatedGif(staticGif)).toBe(false);
});
it('detects animated webp files from the VP8X animation flag', () => {
const animatedWebp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]).buffer;
expect(isAnimatedWebp(animatedWebp)).toBe(true);
});
it('does not mark static webp files as animated', () => {
const staticWebp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]).buffer;
expect(isAnimatedWebp(staticWebp)).toBe(false);
});
});

View File

@@ -0,0 +1,335 @@
import { Injectable } from '@angular/core';
import {
PROFILE_AVATAR_ALLOWED_MIME_TYPES,
PROFILE_AVATAR_OUTPUT_SIZE,
ProfileAvatarTransform,
EditableProfileAvatarSource,
ProcessedProfileAvatar,
clampProfileAvatarTransform,
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
resolveProfileAvatarBaseScale
} from '../../domain/profile-avatar.models';
const PROFILE_AVATAR_OUTPUT_MIME = 'image/webp';
const PROFILE_AVATAR_OUTPUT_QUALITY = 0.92;
export function isAnimatedGif(buffer: ArrayBuffer): boolean {
const bytes = new Uint8Array(buffer);
if (bytes.length < 13 || readAscii(bytes, 0, 6) !== 'GIF87a' && readAscii(bytes, 0, 6) !== 'GIF89a') {
return false;
}
let offset = 13;
if ((bytes[10] & 0x80) !== 0) {
offset += 3 * (2 ** ((bytes[10] & 0x07) + 1));
}
let frameCount = 0;
while (offset < bytes.length) {
const blockType = bytes[offset];
if (blockType === 0x3B) {
return false;
}
if (blockType === 0x21) {
offset += 2;
while (offset < bytes.length) {
const blockSize = bytes[offset++];
if (blockSize === 0) {
break;
}
offset += blockSize;
}
continue;
}
if (blockType !== 0x2C || offset + 10 > bytes.length) {
return false;
}
frameCount++;
if (frameCount > 1) {
return true;
}
const packedFields = bytes[offset + 9];
offset += 10;
if ((packedFields & 0x80) !== 0) {
offset += 3 * (2 ** ((packedFields & 0x07) + 1));
}
offset += 1;
while (offset < bytes.length) {
const blockSize = bytes[offset++];
if (blockSize === 0) {
break;
}
offset += blockSize;
}
}
return false;
}
export function isAnimatedWebp(buffer: ArrayBuffer): boolean {
const bytes = new Uint8Array(buffer);
if (bytes.length < 16 || readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
return false;
}
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkType = readAscii(bytes, offset, 4);
const chunkSize = readUint32LittleEndian(bytes, offset + 4);
if (chunkType === 'ANIM' || chunkType === 'ANMF') {
return true;
}
if (chunkType === 'VP8X' && offset + 9 <= bytes.length) {
const featureFlags = bytes[offset + 8];
if ((featureFlags & 0x02) !== 0) {
return true;
}
}
offset += 8 + chunkSize + (chunkSize % 2);
}
return false;
}
@Injectable({ providedIn: 'root' })
export class ProfileAvatarImageService {
validateFile(file: File): string | null {
const mimeType = file.type.toLowerCase();
const normalizedName = file.name.toLowerCase();
const isAllowedMime = PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES[number]);
const isAllowedExtension = normalizedName.endsWith('.webp')
|| normalizedName.endsWith('.gif')
|| normalizedName.endsWith('.jpg')
|| normalizedName.endsWith('.jpeg');
if (!isAllowedExtension || (mimeType && !isAllowedMime)) {
return 'Invalid file type. Use WebP, GIF, JPG, or JPEG.';
}
return null;
}
async prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
const objectUrl = URL.createObjectURL(file);
const mime = this.resolveSourceMime(file);
try {
const [image, preservesAnimation] = await Promise.all([this.loadImage(objectUrl), this.detectAnimatedSource(file, mime)]);
return {
file,
objectUrl,
mime,
name: file.name,
width: image.naturalWidth,
height: image.naturalHeight,
preservesAnimation
};
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
}
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
if (!source?.objectUrl) {
return;
}
URL.revokeObjectURL(source.objectUrl);
}
async processEditableSource(
source: EditableProfileAvatarSource,
transform: ProfileAvatarTransform
): Promise<ProcessedProfileAvatar> {
if (source.preservesAnimation) {
return this.processAnimatedSource(source);
}
const image = await this.loadImage(source.objectUrl);
const canvas = document.createElement('canvas');
canvas.width = PROFILE_AVATAR_OUTPUT_SIZE;
canvas.height = PROFILE_AVATAR_OUTPUT_SIZE;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas not supported');
}
const clampedTransform = clampProfileAvatarTransform(source, transform);
const previewScale = resolveProfileAvatarBaseScale(source, PROFILE_AVATAR_EDITOR_FRAME_SIZE) * clampedTransform.zoom;
const renderRatio = PROFILE_AVATAR_OUTPUT_SIZE / PROFILE_AVATAR_EDITOR_FRAME_SIZE;
const drawWidth = image.naturalWidth * previewScale * renderRatio;
const drawHeight = image.naturalHeight * previewScale * renderRatio;
const drawX = (PROFILE_AVATAR_OUTPUT_SIZE - drawWidth) / 2 + clampedTransform.offsetX * renderRatio;
const drawY = (PROFILE_AVATAR_OUTPUT_SIZE - drawHeight) / 2 + clampedTransform.offsetY * renderRatio;
context.clearRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
const renderedBlob = await this.canvasToBlob(canvas, PROFILE_AVATAR_OUTPUT_MIME, PROFILE_AVATAR_OUTPUT_QUALITY);
const compressedBlob = renderedBlob;
const updatedAt = Date.now();
const dataUrl = await this.readBlobAsDataUrl(compressedBlob);
const hash = await this.computeHash(compressedBlob);
return {
blob: compressedBlob,
base64: dataUrl.split(',', 2)[1] ?? '',
avatarUrl: dataUrl,
avatarHash: hash,
avatarMime: compressedBlob.type || PROFILE_AVATAR_OUTPUT_MIME,
avatarUpdatedAt: updatedAt,
width: PROFILE_AVATAR_OUTPUT_SIZE,
height: PROFILE_AVATAR_OUTPUT_SIZE
};
}
private async processAnimatedSource(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar> {
const updatedAt = Date.now();
const dataUrl = await this.readBlobAsDataUrl(source.file);
const hash = await this.computeHash(source.file);
return {
blob: source.file,
base64: dataUrl.split(',', 2)[1] ?? '',
avatarUrl: dataUrl,
avatarHash: hash,
avatarMime: source.mime || source.file.type || PROFILE_AVATAR_OUTPUT_MIME,
avatarUpdatedAt: updatedAt,
width: source.width,
height: source.height
};
}
private async detectAnimatedSource(file: File, mime: string): Promise<boolean> {
if (mime !== 'image/gif' && mime !== 'image/webp') {
return false;
}
const buffer = await file.arrayBuffer();
return mime === 'image/gif'
? isAnimatedGif(buffer)
: isAnimatedWebp(buffer);
}
private resolveSourceMime(file: File): string {
const mimeType = file.type.toLowerCase();
if (mimeType === 'image/jpg') {
return 'image/jpeg';
}
if (mimeType) {
return mimeType;
}
const normalizedName = file.name.toLowerCase();
if (normalizedName.endsWith('.gif')) {
return 'image/gif';
}
if (normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (normalizedName.endsWith('.webp')) {
return 'image/webp';
}
return PROFILE_AVATAR_OUTPUT_MIME;
}
private async computeHash(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(digest))
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
}
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Failed to render avatar image'));
}, type, quality);
});
}
private readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
return;
}
reject(new Error('Failed to encode avatar image'));
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
reader.readAsDataURL(blob);
});
}
private loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load avatar image'));
image.src = url;
});
}
}
function readAscii(bytes: Uint8Array, offset: number, length: number): string {
return String.fromCharCode(...bytes.slice(offset, offset + length));
}
function readUint32LittleEndian(bytes: Uint8Array, offset: number): number {
return bytes[offset]
| (bytes[offset + 1] << 8)
| (bytes[offset + 2] << 16)
| (bytes[offset + 3] << 24);
}

View File

@@ -0,0 +1,55 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { User } from '../../../../shared-kernel';
import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
const LEGACY_PROFILE_FILE_NAMES = [
'profile.webp',
'profile.gif',
'profile.jpg',
'profile.jpeg',
'profile.png'
];
@Injectable({ providedIn: 'root' })
export class ProfileAvatarStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
async persistProcessedAvatar(
user: Pick<User, 'id' | 'username' | 'displayName'>,
avatar: Pick<ProcessedProfileAvatar, 'base64' | 'avatarMime'>
): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return;
}
const appDataPath = await electronApi.getAppDataPath();
const usernameSegment = this.sanitizePathSegment(user.username || user.displayName || user.id || 'user');
const directoryPath = `${appDataPath}/user/${usernameSegment}/profile`;
const targetFileName = resolveProfileAvatarStorageFileName(avatar.avatarMime);
await electronApi.ensureDir(directoryPath);
for (const fileName of LEGACY_PROFILE_FILE_NAMES) {
const filePath = `${directoryPath}/${fileName}`;
if (fileName !== targetFileName && await electronApi.fileExists(filePath)) {
await electronApi.deleteFile(filePath);
}
}
await electronApi.writeFile(`${directoryPath}/${targetFileName}`, avatar.base64);
}
private sanitizePathSegment(value: string): string {
const normalized = value
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return normalized || 'user';
}
}

View File

@@ -41,6 +41,6 @@ describe('room-signal-source helpers', () => {
expect(areRoomSignalSourcesEqual( expect(areRoomSignalSourcesEqual(
{ sourceUrl: 'https://signal.toju.app/' }, { sourceUrl: 'https://signal.toju.app/' },
{ signalingUrl: 'wss://signal.toju.app' } { signalingUrl: 'wss://signal.toju.app' }
)).toBeTrue(); )).toBe(true);
}); });
}); });

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