11 Commits

Author SHA1 Message Date
Myx
1656b8a17f fix: multiple bug fixes
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 6m54s
Queue Release Build / build-windows (push) Successful in 16m6s
Queue Release Build / build-linux (push) Successful in 30m58s
Queue Release Build / finalize (push) Successful in 44s
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-24 22:19:57 +02:00
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
229 changed files with 13293 additions and 1560 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)
id: 'e2e-test-server', ? endpointsOrPort.map((endpoint) => ({
name: 'E2E Test Server', id: endpoint.id,
url: `http://localhost:${port}`, name: endpoint.name,
isActive: true, url: endpoint.url,
isDefault: false, isActive: endpoint.isActive ?? true,
status: 'unknown' isDefault: endpoint.isDefault ?? false,
}; status: endpoint.status ?? 'unknown'
}))
: [
{
id: 'e2e-test-server',
name: 'E2E Test Server',
url: `http://localhost:${endpointsOrPort}`,
isActive: true,
isDefault: false,
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,6 +1,7 @@
import { type Page, type Locator } from '@playwright/test'; import { type Page, type Locator } from '@playwright/test';
export class LoginPage { export class LoginPage {
readonly form: Locator;
readonly usernameInput: Locator; readonly usernameInput: Locator;
readonly passwordInput: Locator; readonly passwordInput: Locator;
readonly serverSelect: Locator; readonly serverSelect: Locator;
@@ -9,12 +10,13 @@ export class LoginPage {
readonly registerLink: Locator; readonly registerLink: Locator;
constructor(private page: Page) { constructor(private page: Page) {
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first();
this.usernameInput = page.locator('#login-username'); this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password'); this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server'); this.serverSelect = page.locator('#login-server');
this.submitButton = page.getByRole('button', { name: 'Login' }); this.submitButton = this.form.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive'); this.errorText = page.locator('.text-destructive');
this.registerLink = page.getByRole('button', { name: 'Register' }); this.registerLink = this.form.getByRole('button', { name: 'Register' });
} }
async goto() { async goto() {

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 });
} }
@@ -38,8 +43,11 @@ export class RegisterPage {
async register(username: string, displayName: string, password: string) { async register(username: string, displayName: string, password: string) {
await this.usernameInput.fill(username); await this.usernameInput.fill(username);
await expect(this.usernameInput).toHaveValue(username);
await this.displayNameInput.fill(displayName); await this.displayNameInput.fill(displayName);
await expect(this.displayNameInput).toHaveValue(displayName);
await this.passwordInput.fill(password); await this.passwordInput.fill(password);
await expect(this.passwordInput).toHaveValue(password);
await this.submitButton.click(); await this.submitButton.click();
} }
} }

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

@@ -0,0 +1,269 @@
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 { ChatMessagesPage } from '../../pages/chat-messages.page';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
interface TestUser {
username: string;
displayName: string;
password: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
userDataDir: string;
}
const CLIENT_LAUNCH_ARGS = [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream'
];
test.describe('User session data isolation', () => {
test.describe.configure({ timeout: 240_000 });
test('preserves a user saved rooms and local history across app restarts', async ({ testServer }) => {
const suffix = uniqueName('persist');
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-persist-'));
const alice: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const aliceServerName = `Alice Session Server ${suffix}`;
const aliceMessage = `Alice persisted message ${suffix}`;
let client: PersistentClient | null = null;
try {
client = await launchPersistentClient(userDataDir, testServer.port);
await test.step('Alice registers and creates local chat history', async () => {
await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
});
await test.step('Alice sees the same saved room and message after a full restart', async () => {
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
await rm(userDataDir, { recursive: true, force: true });
}
});
test('gives a new user a blank slate and restores only that user local data after account switches', async ({ testServer }) => {
const suffix = uniqueName('isolation');
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-isolation-'));
const alice: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bob: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const aliceServerName = `Alice Private Server ${suffix}`;
const bobServerName = `Bob Private Server ${suffix}`;
const aliceMessage = `Alice history ${suffix}`;
const bobMessage = `Bob history ${suffix}`;
let client: PersistentClient | null = null;
try {
client = await launchPersistentClient(userDataDir, testServer.port);
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
await logoutUser(client.page);
await registerUser(client.page, bob);
await expectBlankSlate(client.page, [aliceServerName]);
});
await test.step('Bob gets only his own saved room and history after a restart', async () => {
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
await expectSavedRoomHidden(client.page, aliceServerName);
});
await test.step('When Alice logs back in she sees only Alice local data, not Bob data', async () => {
await logoutUser(client.page);
await restartPersistentClient(client, testServer.port);
await loginUser(client.page, alice);
await expectSavedRoomVisible(client.page, aliceServerName);
await expectSavedRoomHidden(client.page, bobServerName);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
await rm(userDataDir, { recursive: true, force: true });
}
});
});
async function launchPersistentClient(userDataDir: string, testServerPort: number): Promise<PersistentClient> {
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();
return {
context,
page,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await client.context.close();
const restartedClient = await launchPersistentClient(client.userDataDir, testServerPort);
client.context = restartedClient.context;
client.page = restartedClient.page;
}
async function closePersistentClient(client: PersistentClient | null): Promise<void> {
if (!client) {
return;
}
await client.context.close().catch(() => {});
}
async function openApp(page: Page): Promise<void> {
await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' }));
}
async function registerUser(page: Page, user: TestUser): Promise<void> {
const registerPage = new RegisterPage(page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function loginUser(page: Page, user: TestUser): Promise<void> {
const loginPage = new LoginPage(page);
await retryTransientNavigation(() => loginPage.goto());
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/(search|room)(\/|$)/, { timeout: 15_000 });
}
async function logoutUser(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
const loginPage = new LoginPage(page);
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
}
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
const messagesPage = new ChatMessagesPage(page);
await searchPage.createServer(serverName, {
description: `User session isolation coverage for ${serverName}`
});
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesPage.sendMessage(messageText);
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
const roomButton = getSavedRoomButton(page, roomName);
const messagesPage = new ChatMessagesPage(page);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
}
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
const searchPage = new ServerSearchPage(page);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
for (const roomName of hiddenRoomNames) {
await expectSavedRoomHidden(page, roomName);
}
}
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getSavedRoomButton(page, roomName)).toHaveCount(0);
}
function getSavedRoomButton(page: Page, roomName: string) {
return page.locator(`button[title="${roomName}"]`).first();
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
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`);
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}

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';
@@ -17,6 +18,81 @@ const DELETED_MESSAGE_CONTENT = '[Message deleted]';
test.describe('Chat messaging features', () => { test.describe('Chat messaging features', () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 });
test('shows per-server channel lists on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `Alpha Server ${uniqueName('rail')}`;
const betaServerName = `Beta Server ${uniqueName('rail')}`;
const alphaChannelName = uniqueName('alpha-updates');
const betaChannelName = uniqueName('beta-plans');
const channelsPanel = scenario.room.channelsSidePanel;
await test.step('Create first saved server with a unique text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail switch alpha server');
await scenario.room.ensureTextChannelExists(alphaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second saved server with a different text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail switch beta server');
await scenario.room.ensureTextChannelExists(betaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toHaveCount(0);
});
await test.step('Opening second server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toHaveCount(0);
});
});
test('shows local room history on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `History Alpha ${uniqueName('rail')}`;
const betaServerName = `History Beta ${uniqueName('rail')}`;
const alphaMessage = `Alpha history message ${uniqueName('msg')}`;
const betaMessage = `Beta history message ${uniqueName('msg')}`;
await test.step('Create first server and send a local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail history alpha server');
await scenario.messages.sendMessage(alphaMessage);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second server and send a different local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail history beta server');
await scenario.messages.sendMessage(betaMessage);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening second server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
});
});
test('syncs messages in a newly created text channel', async ({ createClient }) => { test('syncs messages in a newly created text channel', async ({ createClient }) => {
const scenario = await createChatScenario(createClient); const scenario = await createChatScenario(createClient);
const channelName = uniqueName('updates'); const channelName = uniqueName('updates');
@@ -133,14 +209,51 @@ 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;
}; }
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
room: ChatRoomPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
await installChatFeatureMocks(client.page);
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
credentials.username,
credentials.displayName,
credentials.password
);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
room: new ChatRoomPage(client.page),
search: new ServerSearchPage(client.page)
};
}
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 +283,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 +292,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 +300,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);
@@ -213,6 +329,52 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
}; };
} }
async function createServerAndOpenRoom(
searchPage: ServerSearchPage,
page: Page,
serverName: string,
description: string
): Promise<void> {
await searchPage.createServer(serverName, { description });
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(page, serverName);
}
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 installChatFeatureMocks(page: Page): Promise<void> { async function installChatFeatureMocks(page: Page): Promise<void> {
await page.route('**/api/klipy/config', async (route) => { await page.route('**/api/klipy/config', async (route) => {
await route.fulfill({ await route.fulfill({
@@ -259,6 +421,7 @@ async function installChatFeatureMocks(page: Page): Promise<void> {
siteName: 'Mock Docs' siteName: 'Mock Docs'
}) })
}); });
return; return;
} }
@@ -291,5 +454,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,264 @@
import {
expect,
type Locator,
type Page
} from '@playwright/test';
import {
test,
type Client
} 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 { ChatMessagesPage } from '../../pages/chat-messages.page';
interface DesktopNotificationRecord {
title: string;
body: string;
}
interface NotificationScenario {
alice: Client;
bob: Client;
aliceRoom: ChatRoomPage;
bobRoom: ChatRoomPage;
bobMessages: ChatMessagesPage;
serverName: string;
channelName: string;
}
test.describe('Chat notifications', () => {
test.describe.configure({ timeout: 180_000 });
test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Background notification ${uniqueName('msg')}`;
await test.step('Bob sends a message to Alice\'s inactive channel', async () => {
await clearDesktopNotifications(scenario.alice.page);
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice receives a desktop notification with the channel preview', async () => {
const notification = await waitForDesktopNotification(scenario.alice.page);
expect(notification).toEqual({
title: `${scenario.serverName} · #${scenario.channelName}`,
body: `Bob: ${message}`
});
});
await test.step('Alice sees unread badges for the room and the inactive channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
});
test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Muted notification ${uniqueName('msg')}`;
await test.step('Alice mutes the inactive text channel', async () => {
await muteTextChannel(scenario.alice.page, scenario.channelName);
await clearDesktopNotifications(scenario.alice.page);
});
await test.step('Bob sends a message into the muted channel', async () => {
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice still sees unread badges for the room and channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
await test.step('Alice does not get a muted desktop popup', async () => {
const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500);
expect(notificationAppeared).toBe(false);
});
});
});
async function createNotificationScenario(createClient: () => Promise<Client>): Promise<NotificationScenario> {
const suffix = uniqueName('notify');
const serverName = `Notifications Server ${suffix}`;
const channelName = uniqueName('updates');
const aliceCredentials = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobCredentials = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const alice = await createClient();
const bob = await createClient();
await installDesktopNotificationSpy(alice.page);
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, {
description: 'E2E notification coverage server'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearch = new ServerSearchPage(bob.page);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await bobSearch.searchInput.fill(serverName);
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);
const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page);
await aliceMessages.waitForReady();
await bobMessages.waitForReady();
await aliceRoom.ensureTextChannelExists(channelName);
await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 });
return {
alice,
bob,
aliceRoom,
bobRoom,
bobMessages,
serverName,
channelName
};
}
async function registerUser(page: Page, username: string, displayName: string, password: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function installDesktopNotificationSpy(page: Page): Promise<void> {
await page.addInitScript(() => {
const notifications: DesktopNotificationRecord[] = [];
class MockNotification {
static permission = 'granted';
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
onclick: (() => void) | null = null;
constructor(title: string, options?: NotificationOptions) {
notifications.push({
title,
body: options?.body ?? ''
});
}
close(): void {
return;
}
}
Object.defineProperty(window, '__desktopNotifications', {
value: notifications,
configurable: true
});
Object.defineProperty(window, 'Notification', {
value: MockNotification,
configurable: true,
writable: true
});
});
}
async function clearDesktopNotifications(page: Page): Promise<void> {
await page.evaluate(() => {
(window as WindowWithDesktopNotifications).__desktopNotifications.length = 0;
});
}
async function waitForDesktopNotification(page: Page): Promise<DesktopNotificationRecord> {
await expect.poll(
async () => (await readDesktopNotifications(page)).length,
{
timeout: 20_000,
message: 'Expected a desktop notification to be emitted'
}
).toBeGreaterThan(0);
const notifications = await readDesktopNotifications(page);
return notifications[notifications.length - 1];
}
async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise<boolean> {
try {
await page.waitForFunction(
() => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0,
undefined,
{ timeout }
);
return true;
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return false;
}
throw error;
}
}
async function readDesktopNotifications(page: Page): Promise<DesktopNotificationRecord[]> {
return page.evaluate(() => {
return [...(window as WindowWithDesktopNotifications).__desktopNotifications];
});
}
async function muteTextChannel(page: Page, channelName: string): Promise<void> {
const channelButton = getTextChannelButton(page, channelName);
const contextMenu = page.locator('app-context-menu');
await expect(channelButton).toBeVisible({ timeout: 20_000 });
await channelButton.click({ button: 'right' });
await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 });
await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click();
await expect(contextMenu).toHaveCount(0);
}
function getSavedRoomButton(page: Page, roomName: string): Locator {
return page.locator(`button[title="${roomName}"]`).first();
}
function getTextChannelButton(page: Page, channelName: string): Locator {
return page.locator('app-rooms-side-panel').first()
.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`)
.first();
}
function getUnreadBadge(container: Locator): Locator {
return container.locator('span.rounded-full').first();
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
interface WindowWithDesktopNotifications extends Window {
__desktopNotifications: DesktopNotificationRecord[];
}

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,19 +81,45 @@ async function joinVoiceTogether(
await expect(existingChannel).toBeVisible({ timeout: 10_000 }); await expect(existingChannel).toBeVisible({ timeout: 10_000 });
} }
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
const bobRoom = new ChatRoomPage(bob.page); const bobRoom = new ChatRoomPage(bob.page);
const doJoin = async () => {
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
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 });
// Wait for WebRTC + audio pipeline // Wait for WebRTC + audio pipeline
await waitForPeerConnected(alice.page, 30_000); await waitForPeerConnected(alice.page, 30_000);
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

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
export async function handleGetCurrentUserId(dataSource: DataSource): Promise<string | null> {
const metaRepo = dataSource.getRepository(MetaEntity);
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
return metaRow?.value?.trim() || null;
}

View File

@@ -8,6 +8,7 @@ import {
GetMessageByIdQuery, GetMessageByIdQuery,
GetReactionsForMessageQuery, GetReactionsForMessageQuery,
GetUserQuery, GetUserQuery,
GetCurrentUserIdQuery,
GetRoomQuery, GetRoomQuery,
GetBansForRoomQuery, GetBansForRoomQuery,
IsUserBannedQuery, IsUserBannedQuery,
@@ -19,6 +20,7 @@ import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage'; import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser'; import { handleGetUser } from './handlers/getUser';
import { handleGetCurrentUser } from './handlers/getCurrentUser'; import { handleGetCurrentUser } from './handlers/getCurrentUser';
import { handleGetCurrentUserId } from './handlers/getCurrentUserId';
import { handleGetUsersByRoom } from './handlers/getUsersByRoom'; import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
import { handleGetRoom } from './handlers/getRoom'; import { handleGetRoom } from './handlers/getRoom';
import { handleGetAllRooms } from './handlers/getAllRooms'; import { handleGetAllRooms } from './handlers/getAllRooms';
@@ -34,6 +36,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource), [QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource), [QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource), [QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
[QueryType.GetCurrentUserId]: () => handleGetCurrentUserId(dataSource),
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource), [QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource), [QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource), [QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),

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

@@ -27,6 +27,7 @@ export const QueryType = {
GetReactionsForMessage: 'get-reactions-for-message', GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user', GetUser: 'get-user',
GetCurrentUser: 'get-current-user', GetCurrentUser: 'get-current-user',
GetCurrentUserId: 'get-current-user-id',
GetUsersByRoom: 'get-users-by-room', GetUsersByRoom: 'get-users-by-room',
GetRoom: 'get-room', GetRoom: 'get-room',
GetAllRooms: 'get-all-rooms', GetAllRooms: 'get-all-rooms',
@@ -105,7 +106,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;
@@ -209,6 +215,7 @@ export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; pa
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } } export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> } export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
export interface GetCurrentUserIdQuery { type: typeof QueryType.GetCurrentUserId; payload: Record<string, never> }
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } } export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } } export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> } export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
@@ -224,6 +231,7 @@ export type Query =
| GetReactionsForMessageQuery | GetReactionsForMessageQuery
| GetUserQuery | GetUserQuery
| GetCurrentUserQuery | GetCurrentUserQuery
| GetCurrentUserIdQuery
| GetUsersByRoomQuery | GetUsersByRoomQuery
| GetRoomQuery | GetRoomQuery
| GetAllRoomsQuery | GetAllRoomsQuery

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": {

42
server/README.md Normal file
View File

@@ -0,0 +1,42 @@
# 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.
- `DB_PATH` can override the SQLite database file location.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
- 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.

Binary file not shown.

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 {
@@ -15,13 +17,130 @@ import {
ServerBanEntity ServerBanEntity
} from '../entities'; } from '../entities';
import { serverMigrations } from '../migrations'; import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths'; import {
findExistingPath,
isPackagedRuntime,
resolvePersistentDataPath,
resolveRuntimePath
} from '../runtime-paths';
const DATA_DIR = resolveRuntimePath('data'); const LEGACY_PACKAGED_DB_FILE = path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite'); const LEGACY_PACKAGED_DB_BACKUP = LEGACY_PACKAGED_DB_FILE + '.bak';
function resolveDefaultDbFile(): string {
return isPackagedRuntime()
? resolvePersistentDataPath('metoyou.sqlite')
: LEGACY_PACKAGED_DB_FILE;
}
function resolveDbFile(): string {
const envPath = process.env.DB_PATH;
if (envPath) {
return path.resolve(envPath);
}
return resolveDefaultDbFile();
}
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;
function restoreFromBackup(reason: string): Uint8Array | undefined {
if (!fs.existsSync(DB_BACKUP)) {
console.error(`[DB] ${reason}. No backup available - starting with a fresh database`);
return undefined;
}
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
if (!isValidSqlite(backup)) {
console.error(`[DB] ${reason}. Backup is also invalid - starting with a fresh database`);
return undefined;
}
fs.copyFileSync(DB_BACKUP, DB_FILE);
console.warn('[DB] Restored database from backup', DB_BACKUP);
return backup;
}
async function migrateLegacyPackagedDatabase(): Promise<void> {
if (process.env.DB_PATH || !isPackagedRuntime() || path.resolve(DB_FILE) === path.resolve(LEGACY_PACKAGED_DB_FILE)) {
return;
}
let migrated = false;
if (!fs.existsSync(DB_FILE)) {
if (fs.existsSync(LEGACY_PACKAGED_DB_FILE)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_FILE, DB_FILE);
migrated = true;
} else if (fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_FILE);
migrated = true;
}
}
if (!fs.existsSync(DB_BACKUP) && fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_BACKUP);
migrated = true;
}
if (migrated) {
console.log('[DB] Migrated packaged database files to:', DATA_DIR);
console.log('[DB] Legacy packaged database location was:', LEGACY_PACKAGED_DB_FILE);
}
}
/**
* 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)) {
console.warn(`[DB] ${DB_FILE} is missing - checking backup`);
return restoreFromBackup('Database file missing');
}
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`);
return restoreFromBackup(`Database file is invalid (${data.length} bytes)`);
}
function resolveSqlJsConfig(): { locateFile: (file: string) => string } { function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
return { return {
locateFile: (file) => { locateFile: (file) => {
@@ -35,6 +154,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 +183,9 @@ 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; await migrateLegacyPackagedDatabase();
if (fs.existsSync(DB_FILE)) const database = safeguardDbFile();
database = fs.readFileSync(DB_FILE);
try { try {
applicationDataSource = new DataSource({ applicationDataSource = new DataSource({
@@ -73,7 +208,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 +229,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

@@ -1,6 +1,9 @@
import fs from 'fs'; import fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
const PACKAGED_DATA_DIRECTORY_NAME = 'MetoYou Server';
type PackagedProcess = NodeJS.Process & { pkg?: unknown }; type PackagedProcess = NodeJS.Process & { pkg?: unknown };
function uniquePaths(paths: string[]): string[] { function uniquePaths(paths: string[]): string[] {
@@ -21,6 +24,33 @@ export function resolveRuntimePath(...segments: string[]): string {
return path.join(getRuntimeBaseDir(), ...segments); return path.join(getRuntimeBaseDir(), ...segments);
} }
function resolvePackagedDataDirectory(): string {
const homeDirectory = os.homedir();
switch (process.platform) {
case 'win32':
return path.join(
process.env.APPDATA || path.join(homeDirectory, 'AppData', 'Roaming'),
PACKAGED_DATA_DIRECTORY_NAME
);
case 'darwin':
return path.join(homeDirectory, 'Library', 'Application Support', PACKAGED_DATA_DIRECTORY_NAME);
default:
return path.join(
process.env.XDG_DATA_HOME || path.join(homeDirectory, '.local', 'share'),
PACKAGED_DATA_DIRECTORY_NAME
);
}
}
export function resolvePersistentDataPath(...segments: string[]): string {
if (!isPackagedRuntime()) {
return resolveRuntimePath(...segments);
}
return path.join(resolvePackagedDataDirectory(), ...segments);
}
export function resolveProjectRootPath(...segments: string[]): string { export function resolveProjectRootPath(...segments: string[]): string {
return path.resolve(__dirname, '..', '..', ...segments); return path.resolve(__dirname, '..', '..', ...segments);
} }

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,17 +33,22 @@ 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';
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants'; import { ROOM_URL_PATTERN } from './core/constants';
import {
clearStoredCurrentUserId,
getStoredCurrentUserId
} from './core/storage/current-user-storage';
import { import {
ThemeNodeDirective, ThemeNodeDirective,
ThemePickerOverlayComponent, ThemePickerOverlayComponent,
@@ -92,6 +97,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 +165,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);
}); });
@@ -217,8 +223,19 @@ export class App implements OnInit, OnDestroy {
void this.desktopUpdates.initialize(); void this.desktopUpdates.initialize();
let currentUserId = getStoredCurrentUserId();
await this.databaseService.initialize(); await this.databaseService.initialize();
if (currentUserId) {
const persistedUserId = await this.databaseService.getCurrentUserId();
if (persistedUserId !== currentUserId) {
clearStoredCurrentUserId();
currentUserId = null;
}
}
try { try {
const apiBase = this.servers.getApiBaseUrl(); const apiBase = this.servers.getApiBaseUrl();
@@ -229,29 +246,28 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks(); await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser()); this.userStatus.start();
const currentUrl = this.getCurrentRouteUrl();
this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) { if (!currentUserId) {
if (!this.isPublicRoute(this.router.url)) { if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/login'], { this.router.navigate(['/login'], {
queryParams: { queryParams: {
returnUrl: this.router.url returnUrl: currentUrl
} }
}).catch(() => {}); }).catch(() => {});
} }
} else { } else {
const current = this.router.url; this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
const generalSettings = loadGeneralSettingsFromStorage(); const generalSettings = loadGeneralSettingsFromStorage();
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId); const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
if ( if (
generalSettings.reopenLastViewedChat generalSettings.reopenLastViewedChat
&& lastViewedChat && lastViewedChat
&& (current === '/' || current === '/search') && (currentUrl === '/' || currentUrl === '/search')
) { ) {
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {}); this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
} }
@@ -384,9 +400,31 @@ export class App implements OnInit, OnDestroy {
} }
private isPublicRoute(url: string): boolean { private isPublicRoute(url: string): boolean {
return url === '/login' || const path = this.getRoutePath(url);
url === '/register' ||
url.startsWith('/invite/'); return path === '/login' ||
path === '/register' ||
path.startsWith('/invite/');
}
private getCurrentRouteUrl(): string {
if (typeof window === 'undefined') {
return this.router.url;
}
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
return currentUrl || this.router.url;
}
private getRoutePath(url: string): string {
if (!url) {
return '/';
}
const [path] = url.split(/[?#]/, 1);
return path || '/';
} }
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null { private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {

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

@@ -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

@@ -0,0 +1,59 @@
import { STORAGE_KEY_CURRENT_USER_ID } from '../constants';
const METOYOU_STORAGE_PREFIX = 'metoyou_';
function normaliseStorageUserId(userId?: string | null): string | null {
const trimmedUserId = userId?.trim();
return trimmedUserId || null;
}
export function getStoredCurrentUserId(): string | null {
try {
const raw = normaliseStorageUserId(localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID));
return raw || null;
} catch {
return null;
}
}
export function getUserScopedStorageKey(baseKey: string, userId?: string | null): string {
const scopedUserId = userId === undefined
? getStoredCurrentUserId()
: normaliseStorageUserId(userId);
return scopedUserId
? `${baseKey}__${encodeURIComponent(scopedUserId)}`
: baseKey;
}
export function setStoredCurrentUserId(userId: string): void {
try {
localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, userId);
} catch {}
}
export function clearStoredCurrentUserId(): void {
try {
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
} catch {}
}
export function clearStoredLocalAppData(): void {
try {
const keysToRemove: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key?.startsWith(METOYOU_STORAGE_PREFIX)) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
} catch {}
}

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

@@ -24,7 +24,7 @@ authentication/
## Service overview ## Service overview
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store. `AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store.
```mermaid ```mermaid
graph TD graph TD
@@ -58,6 +58,7 @@ sequenceDiagram
participant SD as ServerDirectoryFacade participant SD as ServerDirectoryFacade
participant API as Server API participant API as Server API
participant Store as NgRx Store participant Store as NgRx Store
participant Effects as UsersEffects
User->>Login: Submit credentials User->>Login: Submit credentials
Login->>Auth: login(username, password) Login->>Auth: login(username, password)
@@ -66,13 +67,15 @@ sequenceDiagram
Auth->>API: POST /api/auth/login Auth->>API: POST /api/auth/login
API-->>Auth: { userId, displayName } API-->>Auth: { userId, displayName }
Auth-->>Login: success Auth-->>Login: success
Login->>Store: UsersActions.setCurrentUser Login->>Store: UsersActions.authenticateUser
Login->>Login: localStorage.setItem(currentUserId) Store->>Effects: prepare persisted user scope
Effects->>Store: reset stale room/user/message state
Effects->>Store: UsersActions.setCurrentUser
``` ```
## Registration flow ## Registration flow
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens. Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
## User bar ## User bar

View File

@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@@ -70,9 +69,7 @@ export class LoginComponent {
joinedAt: Date.now() joinedAt: Date.now()
}; };
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} this.store.dispatch(UsersActions.authenticateUser({ user }));
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) { if (returnUrl?.startsWith('/')) {

View File

@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
@@ -72,9 +71,7 @@ export class RegisterComponent {
joinedAt: Date.now() joinedAt: Date.now()
}; };
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} this.store.dispatch(UsersActions.authenticateUser({ user }));
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) { if (returnUrl?.startsWith('/')) {

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"
<span class="text-foreground">{{ user()?.displayName }}</span> (click)="toggleProfileCard(avatarBtn)"
>
<app-user-avatar
[name]="user()!.displayName"
[avatarUrl]="user()!.avatarUrl"
size="sm"
[status]="user()!.status"
[showStatusBadge]="true"
/>
</button>
</div> </div>
} @else { } @else {
<button <div class="flex flex-col items-center gap-1">
type="button" <button
(click)="goto('login')" type="button"
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1" (click)="goto('login')"
> 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 >
name="lucideLogIn" <ng-icon
class="w-4 h-4" name="lucideLogIn"
/> class="w-3 h-3"
Login />
</button> Login
<button </button>
type="button" <button
(click)="goto('register')" type="button"
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1" (click)="goto('register')"
> 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 >
name="lucideUserPlus" <ng-icon
class="w-4 h-4" name="lucideUserPlus"
/> class="w-3 h-3"
Register />
</button> Register
</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"
> >
<app-user-avatar <div
[name]="msg.senderName" class="flex-shrink-0 cursor-pointer"
size="md" (click)="openSenderProfileCard($event); $event.stopPropagation()"
class="flex-shrink-0" >
/> <app-user-avatar
[name]="senderUser().displayName || msg.senderName"
[avatarUrl]="senderUser().avatarUrl"
size="md"
/>
</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,11 +100,13 @@
@if (msg.linkMetadata?.length) { @if (msg.linkMetadata?.length) {
@for (meta of msg.linkMetadata; track meta.url) { @for (meta of msg.linkMetadata; track meta.url) {
<app-chat-link-embed @if (shouldShowLinkEmbed(meta.url)) {
[metadata]="meta" <app-chat-link-embed
[canRemove]="isOwnMessage() || isAdmin()" [metadata]="meta"
(removed)="removeEmbed(meta.url)" [canRemove]="isOwnMessage() || isAdmin()"
/> (removed)="removeEmbed(meta.url)"
/>
}
} }
} }
@@ -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

@@ -90,7 +90,8 @@ All effects in this domain are `dispatch: false`. The effect layer never owns no
| `hydrateUnreadCounts$` | `loadRoomsSuccess` and `loadCurrentUserSuccess` | Rebuilds unread counts from persisted messages once both rooms and user identity exist | | `hydrateUnreadCounts$` | `loadRoomsSuccess` and `loadCurrentUserSuccess` | Rebuilds unread counts from persisted messages once both rooms and user identity exist |
| `markVisibleChannelRead$` | Room activation and channel selection | Clears unread for the current visible channel | | `markVisibleChannelRead$` | Room activation and channel selection | Clears unread for the current visible channel |
| `handleIncomingMessage$` | `MessagesActions.receiveMessage` | Updates unread counts and triggers desktop delivery for live messages | | `handleIncomingMessage$` | `MessagesActions.receiveMessage` | Updates unread counts and triggers desktop delivery for live messages |
| `refreshCurrentRoomUnread$` | `loadMessagesSuccess` and `syncMessages` | Recomputes unread counts for the active room from the latest message snapshot | | `refreshViewedRoomUnread$` | `loadMessagesSuccess` | Recomputes unread counts for the active room from the latest viewed-room snapshot |
| `refreshSyncedRoomUnread$` | `syncMessages` | Recomputes unread counts for every room represented in a sync batch, including background servers |
## Incoming message flow ## Incoming message flow

View File

@@ -0,0 +1,34 @@
import { type Message } from '../../../../shared-kernel';
import { groupMessagesByRoom } from './notifications.effects';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User 1',
content: 'hello',
timestamp: 1,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('groupMessagesByRoom', () => {
it('groups sync batches by room id', () => {
const grouped = groupMessagesByRoom([
createMessage({ id: 'a1', roomId: 'room-a' }),
createMessage({ id: 'b1', roomId: 'room-b' }),
createMessage({ id: 'a2', roomId: 'room-a' })
]);
expect(Array.from(grouped.keys())).toEqual(['room-a', 'room-b']);
expect(grouped.get('room-a')?.map((message) => message.id)).toEqual(['a1', 'a2']);
expect(grouped.get('room-b')?.map((message) => message.id)).toEqual(['b1']);
});
it('returns empty map for empty sync batch', () => {
expect(groupMessagesByRoom([]).size).toBe(0);
});
});

View File

@@ -11,6 +11,7 @@ import {
tap, tap,
withLatestFrom withLatestFrom
} from 'rxjs/operators'; } from 'rxjs/operators';
import type { Message } from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
@@ -18,6 +19,23 @@ import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { NotificationsFacade } from '../facades/notifications.facade'; import { NotificationsFacade } from '../facades/notifications.facade';
export function groupMessagesByRoom(messages: Message[]): Map<string, Message[]> {
const messagesByRoom = new Map<string, Message[]>();
for (const message of messages) {
const roomMessages = messagesByRoom.get(message.roomId);
if (roomMessages) {
roomMessages.push(message);
continue;
}
messagesByRoom.set(message.roomId, [message]);
}
return messagesByRoom;
}
@Injectable() @Injectable()
export class NotificationsEffects { export class NotificationsEffects {
private readonly actions$ = inject(Actions); private readonly actions$ = inject(Actions);
@@ -92,10 +110,10 @@ export class NotificationsEffects {
{ dispatch: false } { dispatch: false }
); );
refreshCurrentRoomUnread$ = createEffect( refreshViewedRoomUnread$ = createEffect(
() => () =>
this.actions$.pipe( this.actions$.pipe(
ofType(MessagesActions.loadMessagesSuccess, MessagesActions.syncMessages), ofType(MessagesActions.loadMessagesSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([{ messages }, room]) => { tap(([{ messages }, room]) => {
if (room) { if (room) {
@@ -105,4 +123,17 @@ export class NotificationsEffects {
), ),
{ dispatch: false } { dispatch: false }
); );
refreshSyncedRoomUnread$ = createEffect(
() =>
this.actions$.pipe(
ofType(MessagesActions.syncMessages),
tap(({ messages }) => {
for (const [roomId, roomMessages] of groupMessagesByRoom(messages)) {
this.notifications.refreshRoomUnreadFromMessages(roomId, roomMessages);
}
})
)
, { dispatch: false }
);
} }

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}`;

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