Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44588e8789 | |||
| 167c45ba8d | |||
| bd21568726 | |||
| 3ba8a2c9eb |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -60,3 +60,8 @@ dist-server/*
|
|||||||
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
doc/**
|
doc/**
|
||||||
|
|
||||||
|
metoyou.sqlite*
|
||||||
|
metoyou.sqlite
|
||||||
|
|
||||||
|
vitest/
|
||||||
|
|||||||
165
README.md
165
README.md
@@ -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
36
e2e/README.md
Normal 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/`.
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ 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';
|
||||||
|
|
||||||
|
export interface SeededEndpointInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SeededEndpointStorageState {
|
interface SeededEndpointStorageState {
|
||||||
key: string;
|
key: string;
|
||||||
removedKey: string;
|
removedKey: string;
|
||||||
@@ -17,21 +26,32 @@ interface SeededEndpointStorageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSeededEndpointStorageState(
|
function buildSeededEndpointStorageState(
|
||||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
endpointsOrPort: readonly SeededEndpointInput[] | number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
): SeededEndpointStorageState {
|
): SeededEndpointStorageState {
|
||||||
const endpoint = {
|
const endpoints = Array.isArray(endpointsOrPort)
|
||||||
|
? endpointsOrPort.map((endpoint) => ({
|
||||||
|
id: endpoint.id,
|
||||||
|
name: endpoint.name,
|
||||||
|
url: endpoint.url,
|
||||||
|
isActive: endpoint.isActive ?? true,
|
||||||
|
isDefault: endpoint.isDefault ?? false,
|
||||||
|
status: endpoint.status ?? 'unknown'
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
id: 'e2e-test-server',
|
id: 'e2e-test-server',
|
||||||
name: 'E2E Test Server',
|
name: 'E2E Test Server',
|
||||||
url: `http://localhost:${port}`,
|
url: `http://localhost:${endpointsOrPort}`,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
status: 'unknown'
|
status: 'unknown'
|
||||||
};
|
}
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: SERVER_ENDPOINTS_STORAGE_KEY,
|
key: SERVER_ENDPOINTS_STORAGE_KEY,
|
||||||
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
|
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
|
||||||
endpoints: [endpoint]
|
endpoints
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +79,15 @@ 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)
|
||||||
@@ -79,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);
|
||||||
|
}
|
||||||
|
|||||||
132
e2e/helpers/test-server.ts
Normal file
132
e2e/helpers/test-server.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,75 +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 noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate);
|
|
||||||
const noiseData = noiseBuffer.getChannelData(0);
|
|
||||||
|
|
||||||
for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) {
|
|
||||||
noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = audioCtx.createBufferSource();
|
|
||||||
const gain = audioCtx.createGain();
|
|
||||||
|
|
||||||
source.buffer = noiseBuffer;
|
|
||||||
source.loop = true;
|
|
||||||
gain.gain.value = 0.12;
|
|
||||||
|
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
|
||||||
|
|
||||||
source.connect(gain);
|
|
||||||
gain.connect(dest);
|
|
||||||
source.start();
|
|
||||||
|
|
||||||
if (audioCtx.state === 'suspended') {
|
|
||||||
try {
|
|
||||||
await audioCtx.resume();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
|
||||||
const resultStream = new MediaStream();
|
|
||||||
|
|
||||||
syntheticMediaResources.push({ audioCtx, source });
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
synthAudioTrack.addEventListener('ended', () => {
|
|
||||||
try {
|
|
||||||
source.stop();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
void audioCtx.close().catch(() => {});
|
|
||||||
}, { once: true });
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -198,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(
|
||||||
@@ -218,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
|
||||||
|
|||||||
@@ -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, '\\$&');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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',
|
||||||
@@ -22,7 +22,11 @@ export default defineConfig({
|
|||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
permissions: ['microphone', 'camera'],
|
permissions: ['microphone', 'camera'],
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
|
args: [
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
'--use-fake-ui-for-media-stream',
|
||||||
|
'--autoplay-policy=no-user-gesture-required'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
mkdtemp,
|
|
||||||
rm
|
|
||||||
} from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -9,11 +6,9 @@ import {
|
|||||||
type BrowserContext,
|
type BrowserContext,
|
||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import {
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
test,
|
|
||||||
expect
|
|
||||||
} from '../../fixtures/multi-client';
|
|
||||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||||
import { LoginPage } from '../../pages/login.page';
|
import { LoginPage } from '../../pages/login.page';
|
||||||
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';
|
||||||
@@ -40,17 +35,39 @@ interface PersistentClient {
|
|||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProfileMetadata {
|
||||||
|
description?: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||||
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]);
|
const GIF_FRAME_MARKER = Buffer.from([
|
||||||
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
0x21,
|
||||||
0x21, 0xFF, 0x0B,
|
0xF9,
|
||||||
0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30,
|
0x04
|
||||||
0x03, 0x01, 0x00, 0x00, 0x00
|
|
||||||
]);
|
]);
|
||||||
const CLIENT_LAUNCH_ARGS = [
|
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
||||||
'--use-fake-device-for-media-stream',
|
0x21,
|
||||||
'--use-fake-ui-for-media-stream'
|
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';
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
test.describe('Profile avatar sync', () => {
|
test.describe('Profile avatar sync', () => {
|
||||||
@@ -100,6 +117,8 @@ test.describe('Profile avatar sync', () => {
|
|||||||
await joinServerFromSearch(bob.page, serverName);
|
await joinServerFromSearch(bob.page, serverName);
|
||||||
await waitForRoomReady(alice.page);
|
await waitForRoomReady(alice.page);
|
||||||
await waitForRoomReady(bob.page);
|
await waitForRoomReady(bob.page);
|
||||||
|
await waitForConnectedPeerCount(alice.page, 1);
|
||||||
|
await waitForConnectedPeerCount(bob.page, 1);
|
||||||
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,6 +145,8 @@ test.describe('Profile avatar sync', () => {
|
|||||||
await registerUser(carol);
|
await registerUser(carol);
|
||||||
await joinServerFromSearch(carol.page, serverName);
|
await joinServerFromSearch(carol.page, serverName);
|
||||||
await waitForRoomReady(carol.page);
|
await waitForRoomReady(carol.page);
|
||||||
|
await waitForConnectedPeerCount(alice.page, 2);
|
||||||
|
await waitForConnectedPeerCount(carol.page, 1);
|
||||||
|
|
||||||
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
|
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
|
||||||
});
|
});
|
||||||
@@ -177,6 +198,134 @@ test.describe('Profile avatar sync', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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> {
|
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
|
||||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
|
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
|
||||||
const session = await launchPersistentSession(userDataDir, testServerPort);
|
const session = await launchPersistentSession(userDataDir, testServerPort);
|
||||||
@@ -220,6 +369,8 @@ async function launchPersistentSession(
|
|||||||
|
|
||||||
const page = context.pages()[0] ?? await context.newPage();
|
const page = context.pages()[0] ?? await context.newPage();
|
||||||
|
|
||||||
|
await installWebRTCTracking(page);
|
||||||
|
|
||||||
return { context, page };
|
return { context, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +439,43 @@ async function uploadAvatarFromRoomSidebar(
|
|||||||
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
|
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> {
|
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
|
||||||
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
|
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
|
||||||
|
|
||||||
@@ -332,18 +520,73 @@ async function waitForRoomReady(page: Page): Promise<void> {
|
|||||||
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
|
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) {
|
function getUserRow(page: Page, displayName: string) {
|
||||||
const usersSidePanel = page.locator('app-rooms-side-panel').last();
|
const usersSidePanel = page.locator('app-rooms-side-panel').last();
|
||||||
|
|
||||||
return usersSidePanel.locator('[role="button"]').filter({
|
return usersSidePanel.locator('[role="button"]').filter({
|
||||||
has: page.getByText(displayName, { exact: true })
|
has: page.getByText(displayName, { exact: true })
|
||||||
}).first();
|
})
|
||||||
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
||||||
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
|
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> {
|
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
|
||||||
const row = getUserRow(page, displayName);
|
const row = getUserRow(page, displayName);
|
||||||
|
|
||||||
@@ -400,6 +643,14 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected
|
|||||||
}).toBe(expectedDataUrl);
|
}).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> {
|
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
|
||||||
const voiceControls = page.locator('app-voice-controls');
|
const voiceControls = page.locator('app-voice-controls');
|
||||||
|
|
||||||
@@ -431,7 +682,11 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
|||||||
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||||
const commentData = Buffer.from(label, 'ascii');
|
const commentData = Buffer.from(label, 'ascii');
|
||||||
const commentExtension = Buffer.concat([
|
const commentExtension = Buffer.concat([
|
||||||
Buffer.from([0x21, 0xFE, commentData.length]),
|
Buffer.from([
|
||||||
|
0x21,
|
||||||
|
0xFE,
|
||||||
|
commentData.length
|
||||||
|
]),
|
||||||
commentData,
|
commentData,
|
||||||
Buffer.from([0x00])
|
Buffer.from([0x00])
|
||||||
]);
|
]);
|
||||||
@@ -454,5 +709,6 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
waitForVideoFlow,
|
waitForVideoFlow,
|
||||||
waitForOutboundVideoFlow,
|
waitForOutboundVideoFlow,
|
||||||
waitForInboundVideoFlow,
|
waitForInboundVideoFlow,
|
||||||
dumpRtcDiagnostics
|
dumpRtcDiagnostics,
|
||||||
|
installAutoResumeAudioContext
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
@@ -38,7 +39,7 @@ async function registerUser(page: import('@playwright/test').Page, user: typeof
|
|||||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Both users register → Alice creates server → Bob joins. */
|
/** Both users register -> Alice creates server -> Bob joins. */
|
||||||
async function setupServerWithBothUsers(
|
async function setupServerWithBothUsers(
|
||||||
alice: { page: import('@playwright/test').Page },
|
alice: { page: import('@playwright/test').Page },
|
||||||
bob: { page: import('@playwright/test').Page }
|
bob: { page: import('@playwright/test').Page }
|
||||||
@@ -80,11 +81,11 @@ async function joinVoiceTogether(
|
|||||||
await expect(existingChannel).toBeVisible({ timeout: 10_000 });
|
await expect(existingChannel).toBeVisible({ timeout: 10_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bobRoom = new ChatRoomPage(bob.page);
|
||||||
|
const doJoin = async () => {
|
||||||
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
|
||||||
|
|
||||||
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
@@ -93,6 +94,32 @@ async function joinVoiceTogether(
|
|||||||
await waitForPeerConnected(bob.page, 30_000);
|
await waitForPeerConnected(bob.page, 30_000);
|
||||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
await waitForAudioStatsPresent(alice.page, 20_000);
|
||||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
await waitForAudioStatsPresent(bob.page, 20_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
await doJoin();
|
||||||
|
|
||||||
|
// Chromium's --use-fake-device-for-media-stream can produce a silent
|
||||||
|
// capture track on the very first getUserMedia call. If bidirectional
|
||||||
|
// audio doesn't flow within a short window, leave and rejoin voice to
|
||||||
|
// re-acquire the mic (second getUserMedia on a warm device works).
|
||||||
|
const aliceDelta = await waitForAudioFlow(alice.page, 10_000);
|
||||||
|
const bobDelta = await waitForAudioFlow(bob.page, 10_000);
|
||||||
|
const aliceFlowing =
|
||||||
|
(aliceDelta.outboundBytesDelta > 0 || aliceDelta.outboundPacketsDelta > 0) &&
|
||||||
|
(aliceDelta.inboundBytesDelta > 0 || aliceDelta.inboundPacketsDelta > 0);
|
||||||
|
const bobFlowing =
|
||||||
|
(bobDelta.outboundBytesDelta > 0 || bobDelta.outboundPacketsDelta > 0) &&
|
||||||
|
(bobDelta.inboundBytesDelta > 0 || bobDelta.inboundPacketsDelta > 0);
|
||||||
|
|
||||||
|
if (!aliceFlowing || !bobFlowing) {
|
||||||
|
// Leave voice
|
||||||
|
await aliceRoom.disconnectButton.click();
|
||||||
|
await bobRoom.disconnectButton.click();
|
||||||
|
await alice.page.waitForTimeout(2_000);
|
||||||
|
|
||||||
|
// Rejoin
|
||||||
|
await doJoin();
|
||||||
|
}
|
||||||
|
|
||||||
// Expand voice workspace on both clients so the demand-driven screen
|
// Expand voice workspace on both clients so the demand-driven screen
|
||||||
// share request flow can fire (requires connectRemoteShares = true).
|
// share request flow can fire (requires connectRemoteShares = true).
|
||||||
@@ -142,6 +169,20 @@ test.describe('Screen sharing', () => {
|
|||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
await installWebRTCTracking(alice.page);
|
||||||
await installWebRTCTracking(bob.page);
|
await installWebRTCTracking(bob.page);
|
||||||
|
await installAutoResumeAudioContext(alice.page);
|
||||||
|
await installAutoResumeAudioContext(bob.page);
|
||||||
|
|
||||||
|
// Seed deterministic voice settings so noise reduction doesn't
|
||||||
|
// swallow the fake audio tone.
|
||||||
|
const voiceSettings = JSON.stringify({
|
||||||
|
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
|
||||||
|
latencyProfile: 'balanced', includeSystemAudio: false,
|
||||||
|
noiseReduction: false, screenShareQuality: 'balanced',
|
||||||
|
askScreenShareQuality: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||||
|
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||||
|
|
||||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||||
@@ -251,6 +292,18 @@ test.describe('Screen sharing', () => {
|
|||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
await installWebRTCTracking(alice.page);
|
||||||
await installWebRTCTracking(bob.page);
|
await installWebRTCTracking(bob.page);
|
||||||
|
await installAutoResumeAudioContext(alice.page);
|
||||||
|
await installAutoResumeAudioContext(bob.page);
|
||||||
|
|
||||||
|
const voiceSettings = JSON.stringify({
|
||||||
|
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
|
||||||
|
latencyProfile: 'balanced', includeSystemAudio: false,
|
||||||
|
noiseReduction: false, screenShareQuality: 'balanced',
|
||||||
|
askScreenShareQuality: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||||
|
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||||
|
|
||||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||||
@@ -323,6 +376,18 @@ test.describe('Screen sharing', () => {
|
|||||||
|
|
||||||
await installWebRTCTracking(alice.page);
|
await installWebRTCTracking(alice.page);
|
||||||
await installWebRTCTracking(bob.page);
|
await installWebRTCTracking(bob.page);
|
||||||
|
await installAutoResumeAudioContext(alice.page);
|
||||||
|
await installAutoResumeAudioContext(bob.page);
|
||||||
|
|
||||||
|
const voiceSettings = JSON.stringify({
|
||||||
|
inputVolume: 100, outputVolume: 100, audioBitrate: 96,
|
||||||
|
latencyProfile: 'balanced', includeSystemAudio: false,
|
||||||
|
noiseReduction: false, screenShareQuality: 'balanced',
|
||||||
|
askScreenShareQuality: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||||
|
await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings);
|
||||||
|
|
||||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||||
|
|||||||
227
e2e/tests/settings/connectivity-warning.spec.ts
Normal file
227
e2e/tests/settings/connectivity-warning.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
e2e/tests/settings/ice-server-settings.spec.ts
Normal file
126
e2e/tests/settings/ice-server-settings.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
216
e2e/tests/settings/stun-turn-fallback.spec.ts
Normal file
216
e2e/tests/settings/stun-turn-fallback.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
803
e2e/tests/voice/mixed-signal-config-voice.spec.ts
Normal file
803
e2e/tests/voice/mixed-signal-config-voice.spec.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
763
e2e/tests/voice/multi-signal-eight-user-voice.spec.ts
Normal file
763
e2e/tests/voice/multi-signal-eight-user-voice.spec.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
31
electron/README.md
Normal 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.
|
||||||
@@ -10,6 +10,8 @@ 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,
|
avatarHash: user.avatarHash ?? null,
|
||||||
avatarMime: user.avatarMime ?? null,
|
avatarMime: user.avatarMime ?? null,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ 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,
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
avatarMime: row.avatarMime ?? undefined,
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ 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;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
@@ -338,16 +340,18 @@ 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');
|
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
||||||
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
||||||
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
||||||
|
const member: RoomMemberRecord = {
|
||||||
return {
|
|
||||||
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,
|
avatarHash: avatarHash || undefined,
|
||||||
avatarMime: avatarMime || undefined,
|
avatarMime: avatarMime || undefined,
|
||||||
@@ -357,6 +361,12 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
|||||||
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 {
|
||||||
@@ -365,6 +375,11 @@ 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 existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
|
||||||
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
|
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
|
||||||
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
|
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
|
||||||
@@ -377,9 +392,13 @@ 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),
|
||||||
|
description: preferIncomingProfile
|
||||||
|
? (Object.prototype.hasOwnProperty.call(incomingMember, 'description') ? incomingMember.description : existingMember.description)
|
||||||
|
: existingMember.description,
|
||||||
|
profileUpdatedAt: Math.max(existingProfileUpdatedAt, incomingProfileUpdatedAt) || undefined,
|
||||||
avatarUrl: preferIncomingAvatar
|
avatarUrl: preferIncomingAvatar
|
||||||
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
||||||
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
||||||
@@ -780,6 +799,8 @@ 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,
|
avatarHash: member.avatarHash ?? null,
|
||||||
avatarMime: member.avatarMime ?? null,
|
avatarMime: member.avatarMime ?? null,
|
||||||
@@ -930,6 +951,8 @@ 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,
|
avatarHash: row.avatarHash ?? undefined,
|
||||||
avatarMime: row.avatarMime ?? undefined,
|
avatarMime: row.avatarMime ?? undefined,
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ 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;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ 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;
|
||||||
|
|
||||||
|
|||||||
16
electron/migrations/1000000000007-AddUserProfileMetadata.ts
Normal file
16
electron/migrations/1000000000007-AddUserProfileMetadata.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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 }],
|
||||||
|
|||||||
40
server/README.md
Normal file
40
server/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Server
|
||||||
|
|
||||||
|
Node/TypeScript signaling server for MetoYou / Toju. This package owns the public server-directory API, join-request flows, websocket runtime, and server-side persistence.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Run `cd server`.
|
||||||
|
2. Run `npm install`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run dev` starts the server with `ts-node-dev` reload.
|
||||||
|
- `npm run build` compiles TypeScript to `dist/`.
|
||||||
|
- `npm run start` runs the compiled server.
|
||||||
|
- From the repository root, `npm run server:dev`, `npm run server:build`, and `npm run server:start` call the same package commands.
|
||||||
|
|
||||||
|
## Runtime Config
|
||||||
|
|
||||||
|
- The server loads the repository-root `.env` file on startup.
|
||||||
|
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||||
|
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||||
|
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/index.ts` | Bootstrap and server startup |
|
||||||
|
| `src/app/` | Express app composition |
|
||||||
|
| `src/routes/` | REST API routes |
|
||||||
|
| `src/websocket/` | WebSocket runtime and signaling transport |
|
||||||
|
| `src/cqrs/` | Command/query handlers |
|
||||||
|
| `src/config/` | Runtime config loading and normalization |
|
||||||
|
| `src/db/`, `src/entities/`, `src/migrations/` | Persistence layer |
|
||||||
|
| `data/` | Runtime data files such as `variables.json` |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `dist/` and `../dist-server/` are generated output.
|
||||||
|
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.
|
||||||
@@ -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 {
|
||||||
@@ -101,6 +103,23 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the database to disk atomically: write a temp file first,
|
||||||
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
|
*/
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||||
|
await fsp.rename(tmpPath, DB_FILE);
|
||||||
|
} catch (err) {
|
||||||
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getDataSource(): DataSource {
|
export function getDataSource(): DataSource {
|
||||||
if (!applicationDataSource?.isInitialized) {
|
if (!applicationDataSource?.isInitialized) {
|
||||||
throw new Error('DataSource not initialised');
|
throw new Error('DataSource not initialised');
|
||||||
@@ -136,7 +155,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
logging: false,
|
logging: false,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
location: DB_FILE,
|
autoSaveCallback: atomicSave,
|
||||||
sqlJsConfig: resolveSqlJsConfig()
|
sqlJsConfig: resolveSqlJsConfig()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|||||||
staleJoinRequestInterval = null;
|
staleJoinRequestInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
console.log(`\n[Shutdown] ${signal} received - closing database...`);
|
||||||
|
|
||||||
if (listeningServer?.listening) {
|
if (listeningServer?.listening) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import {
|
|||||||
describe,
|
describe,
|
||||||
it,
|
it,
|
||||||
expect,
|
expect,
|
||||||
beforeEach
|
beforeEach,
|
||||||
|
vi
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { WebSocket } from 'ws';
|
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.
|
* Minimal mock WebSocket that records sent messages.
|
||||||
*/
|
*/
|
||||||
@@ -149,7 +154,7 @@ describe('server websocket handler - status_update', () => {
|
|||||||
// Identify first (required for handler)
|
// Identify first (required for handler)
|
||||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
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
|
// user-2 joins server -> should receive server_users with user-1's status
|
||||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||||
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
@@ -197,3 +202,94 @@ describe('server websocket handler - user_joined includes status', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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), status: cu.status ?? 'online' }));
|
.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,8 @@ 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',
|
status: user.status ?? 'online',
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
@@ -220,7 +276,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
|||||||
|
|
||||||
user.status = status as ConnectedUser['status'];
|
user.status = status as ConnectedUser['status'];
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status → ${status}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`);
|
||||||
|
|
||||||
for (const serverId of user.serverIds) {
|
for (const serverId of user.serverIds) {
|
||||||
broadcastToServer(serverId, {
|
broadcastToServer(serverId, {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
42
toju-app/README.md
Normal file
42
toju-app/README.md
Normal 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.
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "2.2MB",
|
"maximumWarning": "2.2MB",
|
||||||
"maximumError": "2.32MB"
|
"maximumError": "2.35MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1508,7 +1508,7 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
if (value.length <= 12)
|
if (value.length <= 12)
|
||||||
return value;
|
return value;
|
||||||
|
|
||||||
return `${value.slice(0, 6)}…${value.slice(-4)}`;
|
return `${value.slice(0, 6)}...${value.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEntryPayloadRecord(payload: unknown): Record<string, unknown> | null {
|
private getEntryPayloadRecord(payload: unknown): Record<string, unknown> | null {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { UserAvatarComponent } from '../../../../shared';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideLogIn,
|
lucideLogIn,
|
||||||
|
|||||||
149
toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts
Normal file
149
toju-app/src/app/domains/chat/domain/rules/link-embed.rules.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
|
|
||||||
@if (msg.linkMetadata?.length) {
|
@if (msg.linkMetadata?.length) {
|
||||||
@for (meta of msg.linkMetadata; track meta.url) {
|
@for (meta of msg.linkMetadata; track meta.url) {
|
||||||
|
@if (shouldShowLinkEmbed(meta.url)) {
|
||||||
<app-chat-link-embed
|
<app-chat-link-embed
|
||||||
[metadata]="meta"
|
[metadata]="meta"
|
||||||
[canRemove]="isOwnMessage() || isAdmin()"
|
[canRemove]="isOwnMessage() || isAdmin()"
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (attachmentsList.length > 0) {
|
@if (attachmentsList.length > 0) {
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
@@ -188,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>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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 { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
|
||||||
import {
|
import {
|
||||||
DELETED_MESSAGE_CONTENT,
|
DELETED_MESSAGE_CONTENT,
|
||||||
Message,
|
Message,
|
||||||
@@ -278,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);
|
||||||
}
|
}
|
||||||
@@ -414,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 {
|
||||||
@@ -497,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
isSoundcloudUrl,
|
||||||
|
isSpotifyUrl,
|
||||||
|
isYoutubeUrl
|
||||||
|
} from '../../../../../domain/rules/link-embed.rules';
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
|
||||||
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from '../chat-youtube-embed/chat-youtube-embed.component';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
<iframe
|
<iframe
|
||||||
[src]="embedUrl()"
|
[src]="embedUrl()"
|
||||||
class="aspect-video w-full"
|
class="aspect-video w-full"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|||||||
@@ -5,8 +5,21 @@ import {
|
|||||||
input
|
input
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { DomSanitizer } from '@angular/platform-browser';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
|
||||||
|
|
||||||
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
|
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({
|
@Component({
|
||||||
selector: 'app-chat-youtube-embed',
|
selector: 'app-chat-youtube-embed',
|
||||||
@@ -16,11 +29,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y
|
|||||||
export class ChatYoutubeEmbedComponent {
|
export class ChatYoutubeEmbedComponent {
|
||||||
readonly url = input.required<string>();
|
readonly url = input.required<string>();
|
||||||
|
|
||||||
readonly videoId = computed(() => {
|
readonly videoId = computed(() => extractYoutubeVideoId(this.url()));
|
||||||
const match = this.url().match(YOUTUBE_URL_PATTERN);
|
|
||||||
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly embedUrl = computed(() => {
|
readonly embedUrl = computed(() => {
|
||||||
const id = this.videoId();
|
const id = this.videoId();
|
||||||
@@ -28,14 +37,16 @@ export class ChatYoutubeEmbedComponent {
|
|||||||
if (!id)
|
if (!id)
|
||||||
return '';
|
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(
|
return this.sanitizer.bypassSecurityTrustResourceUrl(
|
||||||
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
|
embedUrl.toString()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYoutubeUrl(url?: string): boolean {
|
|
||||||
return !!url && YOUTUBE_URL_PATTERN.test(url);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Profile Avatar Domain
|
# Profile Avatar Domain
|
||||||
|
|
||||||
Owns local profile picture workflow: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar sync metadata.
|
Owns local profile picture workflow plus peer-synced profile-card metadata: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar/profile sync metadata.
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ Owns local profile picture workflow: source validation, crop/zoom editor state,
|
|||||||
- Render static avatars to `256x256` WebP with client-side compression.
|
- Render static avatars to `256x256` WebP with client-side compression.
|
||||||
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
|
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
|
||||||
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
|
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
|
||||||
|
- Let the local user edit their profile-card display name and description.
|
||||||
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
|
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
|
||||||
|
- Reuse the avatar summary/request/full handshake to sync profile text (`displayName`, `description`, `profileUpdatedAt`) alongside avatar state.
|
||||||
|
|
||||||
## Module map
|
## Module map
|
||||||
|
|
||||||
@@ -33,12 +35,14 @@ graph TD
|
|||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
1. `ProfileCardComponent` opens file picker from editable avatar button.
|
1. `ProfileCardComponent` opens file picker from editable avatar button.
|
||||||
2. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
|
2. `ProfileCardComponent` saves display-name and description edits through the users store.
|
||||||
3. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
|
3. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
|
||||||
4. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
|
4. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
|
||||||
5. `UserAvatarEffects` broadcasts avatar summary, answers requests, streams chunks, and persists received avatars locally.
|
5. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
|
||||||
|
6. `UserAvatarEffects` broadcasts avatar/profile summaries, answers requests, streams chunks when needed, and persists received profile state locally.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
|
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
|
||||||
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.
|
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.
|
||||||
|
- Profile text uses its own `profileUpdatedAt` version so display-name and description changes can sync without replacing a newer avatar.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
||||||
(click)="cancelled.emit()"
|
(click)="cancelled.emit(undefined)"
|
||||||
(keydown.enter)="cancelled.emit()"
|
(keydown.enter)="cancelled.emit(undefined)"
|
||||||
(keydown.space)="cancelled.emit()"
|
(keydown.space)="cancelled.emit(undefined)"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Close profile image editor"
|
aria-label="Close profile image editor"
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -135,7 +134,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||||
(click)="cancelled.emit()"
|
(click)="cancelled.emit(undefined)"
|
||||||
[disabled]="processing()"
|
[disabled]="processing()"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
export class ProfileAvatarEditorComponent {
|
export class ProfileAvatarEditorComponent {
|
||||||
readonly source = input.required<EditableProfileAvatarSource>();
|
readonly source = input.required<EditableProfileAvatarSource>();
|
||||||
|
|
||||||
readonly cancelled = output<void>();
|
readonly cancelled = output<undefined>();
|
||||||
readonly confirmed = output<ProcessedProfileAvatar>();
|
readonly confirmed = output<ProcessedProfileAvatar>();
|
||||||
|
|
||||||
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||||
@@ -53,7 +53,7 @@ export class ProfileAvatarEditorComponent {
|
|||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscape(): void {
|
onEscape(): void {
|
||||||
if (!this.processing()) {
|
if (!this.processing()) {
|
||||||
this.cancelled.emit();
|
this.cancelled.emit(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||||
Overlay,
|
|
||||||
OverlayRef
|
|
||||||
} from '@angular/cdk/overlay';
|
|
||||||
import { ComponentPortal } from '@angular/cdk/portal';
|
import { ComponentPortal } from '@angular/cdk/portal';
|
||||||
import {
|
import { EditableProfileAvatarSource, ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
||||||
EditableProfileAvatarSource,
|
|
||||||
ProcessedProfileAvatar
|
|
||||||
} from '../../domain/profile-avatar.models';
|
|
||||||
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
||||||
|
|
||||||
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
||||||
@@ -25,7 +19,9 @@ export class ProfileAvatarEditorService {
|
|||||||
const overlayRef = this.overlay.create({
|
const overlayRef = this.overlay.create({
|
||||||
disposeOnNavigation: true,
|
disposeOnNavigation: true,
|
||||||
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||||
positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
|
positionStrategy: this.overlay.position().global()
|
||||||
|
.centerHorizontally()
|
||||||
|
.centerVertically(),
|
||||||
scrollStrategy: this.overlay.scrollStrategies.block()
|
scrollStrategy: this.overlay.scrollStrategies.block()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,7 +51,6 @@ export class ProfileAvatarEditorService {
|
|||||||
overlayRef.dispose();
|
overlayRef.dispose();
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
||||||
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
||||||
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {
|
/* eslint-disable @stylistic/js/array-element-newline */
|
||||||
isAnimatedGif,
|
import { isAnimatedGif, isAnimatedWebp } from './profile-avatar-image.service';
|
||||||
isAnimatedWebp
|
|
||||||
} from './profile-avatar-image.service';
|
|
||||||
|
|
||||||
describe('profile-avatar image animation detection', () => {
|
describe('profile-avatar image animation detection', () => {
|
||||||
it('detects animated gifs with multiple frames', () => {
|
it('detects animated gifs with multiple frames', () => {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { User } from '../../../../shared-kernel';
|
import type { User } from '../../../../shared-kernel';
|
||||||
import {
|
import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
||||||
ProcessedProfileAvatar,
|
|
||||||
resolveProfileAvatarStorageFileName
|
|
||||||
} from '../../domain/profile-avatar.models';
|
|
||||||
|
|
||||||
const LEGACY_PROFILE_FILE_NAMES = [
|
const LEGACY_PROFILE_FILE_NAMES = [
|
||||||
'profile.webp',
|
'profile.webp',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class InviteComponent implements OnInit {
|
|||||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||||
readonly invite = signal<ServerInviteInfo | null>(null);
|
readonly invite = signal<ServerInviteInfo | null>(null);
|
||||||
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
||||||
readonly message = signal('Loading invite…');
|
readonly message = signal('Loading invite...');
|
||||||
|
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
@@ -121,7 +121,7 @@ export class InviteComponent implements OnInit {
|
|||||||
|
|
||||||
this.invite.set(invite);
|
this.invite.set(invite);
|
||||||
this.status.set('joining');
|
this.status.set('joining');
|
||||||
this.message.set(`Joining ${invite.server.name}…`);
|
this.message.set(`Joining ${invite.server.name}...`);
|
||||||
|
|
||||||
const currentUser = await this.hydrateCurrentUser();
|
const currentUser = await this.hydrateCurrentUser();
|
||||||
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||||
@@ -163,7 +163,7 @@ export class InviteComponent implements OnInit {
|
|||||||
|
|
||||||
private async redirectToLogin(): Promise<void> {
|
private async redirectToLogin(): Promise<void> {
|
||||||
this.status.set('redirecting');
|
this.status.set('redirecting');
|
||||||
this.message.set('Redirecting to login…');
|
this.message.set('Redirecting to login...');
|
||||||
|
|
||||||
await this.router.navigate(['/login'], {
|
await this.router.navigate(['/login'], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export class VoiceConnectionFacade {
|
|||||||
return this.realtime.getRawMicStream();
|
return this.realtime.getRawMicStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportConnectionError(message: string): void {
|
||||||
|
this.realtime.reportConnectionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearConnectionError(): void {
|
||||||
|
this.realtime.clearConnectionError();
|
||||||
|
}
|
||||||
|
|
||||||
async enableVoice(): Promise<MediaStream> {
|
async enableVoice(): Promise<MediaStream> {
|
||||||
return await this.realtime.enableVoice();
|
return await this.realtime.enableVoice();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
inject,
|
||||||
|
computed,
|
||||||
|
type Signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import type { User } from '../../../../shared-kernel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connectivity health status for a single peer in voice.
|
||||||
|
*/
|
||||||
|
export interface PeerConnectivityHealth {
|
||||||
|
peerId: string;
|
||||||
|
/** Number of voice peers this peer can send/receive audio to/from. */
|
||||||
|
connectedPeerCount: number;
|
||||||
|
/** Total peers expected in voice. */
|
||||||
|
totalVoicePeers: number;
|
||||||
|
/** true when this peer has the fewest connections -> warning target. */
|
||||||
|
hasDesync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks per-peer voice connectivity health by comparing the number
|
||||||
|
* of connected audio streams each peer has. Peers with fewest
|
||||||
|
* bidirectional audio connections are flagged.
|
||||||
|
*
|
||||||
|
* Uses peer latency data as proxy for healthy bidirectional connection.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class VoiceConnectivityHealthService {
|
||||||
|
readonly currentUser: Signal<User | null | undefined>;
|
||||||
|
readonly onlineUsers: Signal<User[]>;
|
||||||
|
readonly desyncPeerIds: Signal<ReadonlySet<string>>;
|
||||||
|
readonly localUserHasDesync: Signal<boolean>;
|
||||||
|
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const store = inject(Store);
|
||||||
|
|
||||||
|
this.currentUser = toSignal(store.select(selectCurrentUser));
|
||||||
|
this.onlineUsers = toSignal(store.select(selectOnlineUsers), { initialValue: [] });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of peerId -> true for peers that have connectivity issues.
|
||||||
|
* A peer is flagged when it has fewer healthy connections than the
|
||||||
|
* majority of users in the same voice channel.
|
||||||
|
*/
|
||||||
|
this.desyncPeerIds = computed<ReadonlySet<string>>(() => {
|
||||||
|
const me = this.currentUser();
|
||||||
|
const myVoice = me?.voiceState;
|
||||||
|
|
||||||
|
if (!myVoice?.isConnected || !myVoice.roomId || !myVoice.serverId) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all users in same voice room
|
||||||
|
const voiceUsers = this.onlineUsers().filter(
|
||||||
|
(user) =>
|
||||||
|
user.voiceState?.isConnected
|
||||||
|
&& user.voiceState.roomId === myVoice.roomId
|
||||||
|
&& user.voiceState.serverId === myVoice.serverId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (voiceUsers.length < 2) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use peer latencies as proxy. A peer we can ping has a working
|
||||||
|
// data-channel (= working RTCPeerConnection). Peers without latency
|
||||||
|
// measurements are considered unreachable.
|
||||||
|
const connectedPeers = this.webrtc.connectedPeers();
|
||||||
|
const connectedSet = new Set(connectedPeers);
|
||||||
|
const myKey = me?.oderId || me?.id;
|
||||||
|
|
||||||
|
if (!myKey) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count how many voice peers each voice user is connected to (from
|
||||||
|
// the local perspective). We can only see our own connections - but
|
||||||
|
// if WE can't reach peer X while we CAN reach peers Y and Z, peer X
|
||||||
|
// is the one with issues.
|
||||||
|
const unreachableFromUs = new Set<string>();
|
||||||
|
|
||||||
|
for (const user of voiceUsers) {
|
||||||
|
const key = user.oderId || user.id;
|
||||||
|
|
||||||
|
if (key === myKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasConnection = connectedSet.has(key)
|
||||||
|
|| connectedSet.has(user.id)
|
||||||
|
|| connectedSet.has(user.oderId ?? '');
|
||||||
|
|
||||||
|
if (!hasConnection) {
|
||||||
|
unreachableFromUs.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can reach everyone, no desync
|
||||||
|
if (unreachableFromUs.size === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't reach ANYONE, the problem is likely on our end
|
||||||
|
const reachableCount = voiceUsers.length - 1 - unreachableFromUs.size;
|
||||||
|
|
||||||
|
if (reachableCount === 0 && voiceUsers.length > 2) {
|
||||||
|
// Everyone unreachable from us -> WE are the problem
|
||||||
|
return new Set([myKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unreachableFromUs;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the LOCAL user is the one with connectivity issues.
|
||||||
|
*/
|
||||||
|
this.localUserHasDesync = computed(() => {
|
||||||
|
const me = this.currentUser();
|
||||||
|
const myKey = me?.oderId || me?.id;
|
||||||
|
|
||||||
|
return !!myKey && this.desyncPeerIds().has(myKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific peer has a desync warning.
|
||||||
|
*/
|
||||||
|
hasPeerDesync(peerKey: string): boolean {
|
||||||
|
return this.desyncPeerIds().has(peerKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,7 +209,7 @@ export class VoicePlaybackService {
|
|||||||
* ↓
|
* ↓
|
||||||
* muted <audio> element (Chrome workaround - primes the stream)
|
* muted <audio> element (Chrome workaround - primes the stream)
|
||||||
* ↓
|
* ↓
|
||||||
* MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
|
* MediaStreamSource -> GainNode -> MediaStreamDestination -> output <audio>
|
||||||
*/
|
*/
|
||||||
private createPipeline(peerId: string, stream: MediaStream): void {
|
private createPipeline(peerId: string, stream: MediaStream): void {
|
||||||
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './application/facades/voice-connection.facade';
|
export * from './application/facades/voice-connection.facade';
|
||||||
export * from './application/services/voice-activity.service';
|
export * from './application/services/voice-activity.service';
|
||||||
export * from './application/services/voice-playback.service';
|
export * from './application/services/voice-playback.service';
|
||||||
|
export * from './application/services/voice-connectivity-health.service';
|
||||||
export * from './domain/models/voice-connection.model';
|
export * from './domain/models/voice-connection.model';
|
||||||
|
|||||||
@@ -235,8 +235,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.voicePlayback.playPendingStreams(this.playbackOptions());
|
this.voicePlayback.playPendingStreams(this.playbackOptions());
|
||||||
|
|
||||||
// Persist settings after successful connection
|
// Persist settings after successful connection
|
||||||
|
this.webrtcService.clearConnectionError();
|
||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
} catch (_error) {}
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to connect voice session.';
|
||||||
|
|
||||||
|
this.webrtcService.reportConnectionError(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry connection when there's a connection error
|
// Retry connection when there's a connection error
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
|
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
|
||||||
(click)="selectTextChannel(ch.id)"
|
(click)="selectTextChannel(ch.id)"
|
||||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||||
|
data-channel-type="text"
|
||||||
|
[attr.data-channel-name]="ch.name"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground text-base">#</span>
|
<span class="text-muted-foreground text-base">#</span>
|
||||||
@if (renamingChannelId() === ch.id) {
|
@if (renamingChannelId() === ch.id) {
|
||||||
@@ -129,6 +131,8 @@
|
|||||||
[class.bg-secondary]="isCurrentRoom(ch.id)"
|
[class.bg-secondary]="isCurrentRoom(ch.id)"
|
||||||
[disabled]="!voiceEnabled()"
|
[disabled]="!voiceEnabled()"
|
||||||
[title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'"
|
[title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'"
|
||||||
|
data-channel-type="voice"
|
||||||
|
[attr.data-channel-name]="ch.name"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-2 text-foreground/80">
|
<span class="flex items-center gap-2 text-foreground/80">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -185,6 +189,14 @@
|
|||||||
[ringClass]="getVoiceUserRingClass(u)"
|
[ringClass]="getVoiceUserRingClass(u)"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
|
<!-- Connectivity warning -->
|
||||||
|
@if (hasConnectivityIssue(u)) {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideAlertTriangle"
|
||||||
|
class="w-3.5 h-3.5 text-amber-500 shrink-0"
|
||||||
|
title="Connection issue - this user may not hear all participants. Consider adding a TURN server in Settings -> Network."
|
||||||
|
/>
|
||||||
|
}
|
||||||
<!-- Ping latency indicator -->
|
<!-- Ping latency indicator -->
|
||||||
@if (u.id !== currentUser()?.id) {
|
@if (u.id !== currentUser()?.id) {
|
||||||
<span
|
<span
|
||||||
@@ -395,6 +407,15 @@
|
|||||||
|
|
||||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||||
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
|
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
|
||||||
|
@if (localUserHasDesync()) {
|
||||||
|
<div class="mx-2 mb-1 flex items-center gap-2 rounded-md bg-amber-500/15 px-3 py-2 text-xs text-amber-400">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideAlertTriangle"
|
||||||
|
class="w-4 h-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<span>You may have connectivity issues. Adding a TURN server in Settings -> Network may help.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div
|
<div
|
||||||
class="border-t border-border px-2 py-3"
|
class="border-t border-border px-2 py-3"
|
||||||
[class.invisible]="showFloatingControls()"
|
[class.invisible]="showFloatingControls()"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
|
lucideAlertTriangle,
|
||||||
lucideMonitor,
|
lucideMonitor,
|
||||||
lucideVideo,
|
lucideVideo,
|
||||||
lucideHash,
|
lucideHash,
|
||||||
@@ -35,7 +36,11 @@ import { MessagesActions } from '../../../store/messages/messages.actions';
|
|||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||||
import { NotificationsFacade } from '../../../domains/notifications';
|
import { NotificationsFacade } from '../../../domains/notifications';
|
||||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
|
import {
|
||||||
|
VoiceActivityService,
|
||||||
|
VoiceConnectionFacade,
|
||||||
|
VoiceConnectivityHealthService
|
||||||
|
} from '../../../domains/voice-connection';
|
||||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||||
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||||
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
||||||
@@ -83,6 +88,7 @@ type PanelMode = 'channels' | 'users';
|
|||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
|
lucideAlertTriangle,
|
||||||
lucideMonitor,
|
lucideMonitor,
|
||||||
lucideVideo,
|
lucideVideo,
|
||||||
lucideHash,
|
lucideHash,
|
||||||
@@ -104,6 +110,7 @@ export class RoomsSidePanelComponent {
|
|||||||
private voicePlayback = inject(VoicePlaybackService);
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
private profileCard = inject(ProfileCardService);
|
private profileCard = inject(ProfileCardService);
|
||||||
private readonly voiceActivity = inject(VoiceActivityService);
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
|
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||||
|
|
||||||
readonly panelMode = input<PanelMode>('channels');
|
readonly panelMode = input<PanelMode>('channels');
|
||||||
readonly showVoiceControls = input(true);
|
readonly showVoiceControls = input(true);
|
||||||
@@ -115,6 +122,7 @@ export class RoomsSidePanelComponent {
|
|||||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||||
|
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
||||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||||
roomMemberIdentifiers = computed(() => {
|
roomMemberIdentifiers = computed(() => {
|
||||||
const identifiers = new Set<string>();
|
const identifiers = new Set<string>();
|
||||||
@@ -199,6 +207,8 @@ export class RoomsSidePanelComponent {
|
|||||||
oderId: member.oderId || member.id,
|
oderId: member.oderId || member.id,
|
||||||
username: member.username,
|
username: member.username,
|
||||||
displayName: member.displayName,
|
displayName: member.displayName,
|
||||||
|
description: member.description,
|
||||||
|
profileUpdatedAt: member.profileUpdatedAt,
|
||||||
avatarUrl: member.avatarUrl,
|
avatarUrl: member.avatarUrl,
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
role: member.role,
|
role: member.role,
|
||||||
@@ -246,6 +256,10 @@ export class RoomsSidePanelComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasConnectivityIssue(user: User): boolean {
|
||||||
|
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
|
||||||
|
}
|
||||||
|
|
||||||
canManageChannels(): boolean {
|
canManageChannels(): boolean {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
@@ -557,23 +571,32 @@ export class RoomsSidePanelComponent {
|
|||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
if (this.openExistingVoiceWorkspace(room, current ?? null, roomId)) {
|
if (this.openExistingVoiceWorkspace(room, current ?? null, roomId)) {
|
||||||
|
this.voiceConnection.clearConnectionError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!room || !this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) {
|
if (!room) {
|
||||||
|
this.voiceConnection.reportConnectionError('No active room selected for voice join.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) {
|
||||||
|
this.voiceConnection.reportConnectionError('You do not have permission to join this voice channel.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
|
if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
|
||||||
|
this.voiceConnection.reportConnectionError('Disconnect from the current voice server before joining a different server.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||||
.catch(() => undefined);
|
.catch((error) => this.handleVoiceJoinFailure(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||||
|
this.voiceConnection.clearConnectionError();
|
||||||
this.updateVoiceStateStore(roomId, room, current);
|
this.updateVoiceStateStore(roomId, room, current);
|
||||||
this.trackCurrentUserMic();
|
this.trackCurrentUserMic();
|
||||||
this.startVoiceHeartbeat(roomId, room);
|
this.startVoiceHeartbeat(roomId, room);
|
||||||
@@ -581,6 +604,14 @@ export class RoomsSidePanelComponent {
|
|||||||
this.startVoiceSession(roomId, room);
|
this.startVoiceSession(roomId, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleVoiceJoinFailure(error: unknown): void {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to join voice channel.';
|
||||||
|
|
||||||
|
this.voiceConnection.reportConnectionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
private trackCurrentUserMic(): void {
|
private trackCurrentUserMic(): void {
|
||||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||||
const micStream = this.voiceConnection.getRawMicStream();
|
const micStream = this.voiceConnection.getRawMicStream();
|
||||||
|
|||||||
@@ -489,16 +489,9 @@ export class ServersRailComponent {
|
|||||||
ensureEndpoint: !!resolvedRoom.sourceUrl
|
ensureEndpoint: !!resolvedRoom.sourceUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
const authoritativeServer = (
|
const authoritativeServer = selector
|
||||||
selector
|
|
||||||
? await firstValueFrom(this.serverDirectory.getServer(room.id, selector))
|
? await firstValueFrom(this.serverDirectory.getServer(room.id, selector))
|
||||||
: null
|
: null;
|
||||||
) ?? await firstValueFrom(this.serverDirectory.findServerAcrossActiveEndpoints(room.id, {
|
|
||||||
sourceId: resolvedRoom.sourceId,
|
|
||||||
sourceName: resolvedRoom.sourceName,
|
|
||||||
sourceUrl: resolvedRoom.sourceUrl,
|
|
||||||
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!authoritativeServer) {
|
if (!authoritativeServer) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<section data-testid="ice-server-settings">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideShield"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground">ICE Servers (STUN / TURN)</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="ice-restore-defaults"
|
||||||
|
(click)="restoreDefaults()"
|
||||||
|
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideRotateCcw"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
|
Restore Defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground mb-3">
|
||||||
|
ICE servers are used for NAT traversal. STUN discovers your public address; TURN relays traffic when direct connections fail. Higher entries have
|
||||||
|
priority.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- ICE Server List -->
|
||||||
|
<div
|
||||||
|
class="space-y-2 mb-3"
|
||||||
|
data-testid="ice-server-list"
|
||||||
|
>
|
||||||
|
@for (entry of entries(); track trackEntry($index, entry); let i = $index) {
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-2.5 rounded-lg border transition-colors"
|
||||||
|
[class.border-blue-500/40]="entry.type === 'turn'"
|
||||||
|
[class.bg-blue-500/5]="entry.type === 'turn'"
|
||||||
|
[class.border-border]="entry.type === 'stun'"
|
||||||
|
[class.bg-secondary/30]="entry.type === 'stun'"
|
||||||
|
[attr.data-testid]="'ice-entry-' + i"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded"
|
||||||
|
[class.bg-muted]="entry.type === 'stun'"
|
||||||
|
[class.text-muted-foreground]="entry.type === 'stun'"
|
||||||
|
[class.bg-blue-500/15]="entry.type === 'turn'"
|
||||||
|
[class.text-blue-400]="entry.type === 'turn'"
|
||||||
|
>
|
||||||
|
{{ entry.type }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm text-foreground truncate">{{ entry.urls }}</p>
|
||||||
|
@if (entry.type === 'turn' && entry.username) {
|
||||||
|
<p class="text-[10px] text-muted-foreground truncate">User: {{ entry.username }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="moveUp(i)"
|
||||||
|
[disabled]="i === 0"
|
||||||
|
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
|
||||||
|
title="Move up (higher priority)"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideArrowUp"
|
||||||
|
class="w-3.5 h-3.5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="moveDown(i)"
|
||||||
|
[disabled]="i === entries().length - 1"
|
||||||
|
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
|
||||||
|
title="Move down (lower priority)"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideArrowDown"
|
||||||
|
class="w-3.5 h-3.5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="removeEntry(entry.id)"
|
||||||
|
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="w-3.5 h-3.5 text-muted-foreground hover:text-destructive"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (entries().length === 0) {
|
||||||
|
<p class="text-xs text-muted-foreground italic py-2">No ICE servers configured. P2P connections may fail across networks.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add New ICE Server -->
|
||||||
|
<div class="border-t border-border pt-3">
|
||||||
|
<h4 class="text-xs font-medium text-foreground mb-2">Add ICE Server</h4>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select
|
||||||
|
[(ngModel)]="newType"
|
||||||
|
data-testid="ice-type-select"
|
||||||
|
class="px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="stun">STUN</option>
|
||||||
|
<option value="turn">TURN</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newUrl"
|
||||||
|
data-testid="ice-url-input"
|
||||||
|
[placeholder]="newType === 'stun' ? 'stun:stun.example.com:19302' : 'turn:turn.example.com:3478'"
|
||||||
|
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (newType === 'turn') {
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newUsername"
|
||||||
|
data-testid="ice-username-input"
|
||||||
|
placeholder="Username"
|
||||||
|
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="newCredential"
|
||||||
|
data-testid="ice-credential-input"
|
||||||
|
placeholder="Credential"
|
||||||
|
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="ice-add-button"
|
||||||
|
(click)="addEntry()"
|
||||||
|
[disabled]="!newUrl"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
|
Add Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (addError()) {
|
||||||
|
<p class="text-xs text-destructive mt-1.5">{{ addError() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import {
|
||||||
|
lucideShield,
|
||||||
|
lucidePlus,
|
||||||
|
lucideTrash2,
|
||||||
|
lucideArrowUp,
|
||||||
|
lucideArrowDown,
|
||||||
|
lucideRotateCcw
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ice-server-settings',
|
||||||
|
standalone: true,
|
||||||
|
host: {
|
||||||
|
style: 'display: block;'
|
||||||
|
},
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon
|
||||||
|
],
|
||||||
|
viewProviders: [
|
||||||
|
provideIcons({
|
||||||
|
lucideShield,
|
||||||
|
lucidePlus,
|
||||||
|
lucideTrash2,
|
||||||
|
lucideArrowUp,
|
||||||
|
lucideArrowDown,
|
||||||
|
lucideRotateCcw
|
||||||
|
})
|
||||||
|
],
|
||||||
|
templateUrl: './ice-server-settings.component.html'
|
||||||
|
})
|
||||||
|
export class IceServerSettingsComponent {
|
||||||
|
private iceSettings = inject(IceServerSettingsService);
|
||||||
|
|
||||||
|
entries = this.iceSettings.entries;
|
||||||
|
|
||||||
|
addError = signal<string | null>(null);
|
||||||
|
newType: 'stun' | 'turn' = 'stun';
|
||||||
|
newUrl = '';
|
||||||
|
newUsername = '';
|
||||||
|
newCredential = '';
|
||||||
|
|
||||||
|
addEntry(): void {
|
||||||
|
this.addError.set(null);
|
||||||
|
|
||||||
|
const url = this.newUrl.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
this.addError.set('URL is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = this.newType === 'stun' ? 'stun:' : 'turn';
|
||||||
|
|
||||||
|
if (!url.startsWith(prefix) && !url.startsWith('turns:')) {
|
||||||
|
this.addError.set(`URL must start with ${this.newType === 'stun' ? 'stun:' : 'turn: or turns:'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.newType === 'turn' && !this.newUsername.trim()) {
|
||||||
|
this.addError.set('Username is required for TURN servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.newType === 'turn' && !this.newCredential.trim()) {
|
||||||
|
this.addError.set('Credential is required for TURN servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.entries().some((entry) => entry.urls === url)) {
|
||||||
|
this.addError.set('This URL already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.iceSettings.addEntry({
|
||||||
|
type: this.newType,
|
||||||
|
urls: url,
|
||||||
|
...(this.newType === 'turn'
|
||||||
|
? { username: this.newUsername.trim(), credential: this.newCredential.trim() }
|
||||||
|
: {})
|
||||||
|
});
|
||||||
|
|
||||||
|
this.newUrl = '';
|
||||||
|
this.newUsername = '';
|
||||||
|
this.newCredential = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEntry(id: string): void {
|
||||||
|
this.iceSettings.removeEntry(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveUp(index: number): void {
|
||||||
|
if (index > 0)
|
||||||
|
this.iceSettings.moveEntry(index, index - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveDown(index: number): void {
|
||||||
|
if (index < this.entries().length - 1)
|
||||||
|
this.iceSettings.moveEntry(index, index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDefaults(): void {
|
||||||
|
this.iceSettings.restoreDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackEntry(_index: number, entry: IceServerEntry): string {
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -199,4 +199,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ICE Server Settings (STUN / TURN) -->
|
||||||
|
<app-ice-server-settings />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
|
|
||||||
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
|
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
|
||||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||||
|
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-network-settings',
|
selector: 'app-network-settings',
|
||||||
@@ -27,7 +28,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon
|
NgIcon,
|
||||||
|
IceServerSettingsComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
[value]="selectedServerId() || ''"
|
[value]="selectedServerId() || ''"
|
||||||
(change)="onServerSelect($event)"
|
(change)="onServerSelect($event)"
|
||||||
>
|
>
|
||||||
<option value="">Select a server…</option>
|
<option value="">Select a server...</option>
|
||||||
@for (room of manageableRooms(); track room.id) {
|
@for (room of manageableRooms(); track room.id) {
|
||||||
<option [value]="room.id">{{ room.name }}</option>
|
<option [value]="room.id">{{ room.name }}</option>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
[value]="state().preferredVersion || ''"
|
[value]="state().preferredVersion || ''"
|
||||||
(change)="onVersionChange($event)"
|
(change)="onVersionChange($event)"
|
||||||
>
|
>
|
||||||
<option value="">Choose a release…</option>
|
<option value="">Choose a release...</option>
|
||||||
@for (version of state().availableVersions; track version) {
|
@for (version of state().availableVersions; track version) {
|
||||||
<option [value]="version">{{ version }}</option>
|
<option [value]="version">{{ version }}</option>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
[disabled]="!params()!.editFlags.canCut"
|
[disabled]="!params()!.editFlags.canCut"
|
||||||
[class.opacity-40]="!params()!.editFlags.canCut"
|
[class.opacity-40]="!params()!.editFlags.canCut"
|
||||||
(click)="execCommand('cut')"
|
(pointerdown)="onActionPointerDown($event, 'cut')"
|
||||||
|
(click)="onActionClick($event, 'cut')"
|
||||||
>
|
>
|
||||||
Cut
|
Cut
|
||||||
</button>
|
</button>
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
[disabled]="!params()!.editFlags.canCopy"
|
[disabled]="!params()!.editFlags.canCopy"
|
||||||
[class.opacity-40]="!params()!.editFlags.canCopy"
|
[class.opacity-40]="!params()!.editFlags.canCopy"
|
||||||
(click)="execCommand('copy')"
|
(pointerdown)="onActionPointerDown($event, 'copy')"
|
||||||
|
(click)="onActionClick($event, 'copy')"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
[disabled]="!params()!.editFlags.canPaste"
|
[disabled]="!params()!.editFlags.canPaste"
|
||||||
[class.opacity-40]="!params()!.editFlags.canPaste"
|
[class.opacity-40]="!params()!.editFlags.canPaste"
|
||||||
(click)="execCommand('paste')"
|
(pointerdown)="onActionPointerDown($event, 'paste')"
|
||||||
|
(click)="onActionClick($event, 'paste')"
|
||||||
>
|
>
|
||||||
Paste
|
Paste
|
||||||
</button>
|
</button>
|
||||||
@@ -39,7 +42,8 @@
|
|||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
[disabled]="!params()!.editFlags.canSelectAll"
|
[disabled]="!params()!.editFlags.canSelectAll"
|
||||||
[class.opacity-40]="!params()!.editFlags.canSelectAll"
|
[class.opacity-40]="!params()!.editFlags.canSelectAll"
|
||||||
(click)="execCommand('selectAll')"
|
(pointerdown)="onActionPointerDown($event, 'selectAll')"
|
||||||
|
(click)="onActionClick($event, 'selectAll')"
|
||||||
>
|
>
|
||||||
Select All
|
Select All
|
||||||
</button>
|
</button>
|
||||||
@@ -47,7 +51,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
(click)="execCommand('copy')"
|
(pointerdown)="onActionPointerDown($event, 'copy')"
|
||||||
|
(click)="onActionClick($event, 'copy')"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
@@ -60,7 +65,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
(click)="copyLink()"
|
(pointerdown)="onActionPointerDown($event, 'copyLink')"
|
||||||
|
(click)="onActionClick($event, 'copyLink')"
|
||||||
>
|
>
|
||||||
Copy Link
|
Copy Link
|
||||||
</button>
|
</button>
|
||||||
@@ -73,7 +79,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="context-menu-item"
|
class="context-menu-item"
|
||||||
(click)="copyImage()"
|
(pointerdown)="onActionPointerDown($event, 'copyImage')"
|
||||||
|
(click)="onActionClick($event, 'copyImage')"
|
||||||
>
|
>
|
||||||
Copy Image
|
Copy Image
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,13 +2,48 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
import { ContextMenuComponent } from '../../../shared';
|
import { ContextMenuComponent } from '../../../shared';
|
||||||
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||||
|
|
||||||
|
type ContextMenuCommand = 'cut' | 'copy' | 'paste' | 'selectAll';
|
||||||
|
type ContextMenuAction = ContextMenuCommand | 'copyLink' | 'copyImage';
|
||||||
|
type TextControlElement = HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
type ContextMenuTarget = TextControlElement | HTMLElement;
|
||||||
|
|
||||||
|
interface ContextMenuSelectionSnapshot {
|
||||||
|
range: Range | null;
|
||||||
|
selectedText: string;
|
||||||
|
selectionDirection: 'forward' | 'backward' | 'none' | null;
|
||||||
|
selectionEnd: number | null;
|
||||||
|
selectionStart: number | null;
|
||||||
|
target: ContextMenuTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NON_TEXT_INPUT_TYPES = new Set([
|
||||||
|
'button',
|
||||||
|
'checkbox',
|
||||||
|
'color',
|
||||||
|
'date',
|
||||||
|
'datetime-local',
|
||||||
|
'file',
|
||||||
|
'hidden',
|
||||||
|
'image',
|
||||||
|
'month',
|
||||||
|
'number',
|
||||||
|
'radio',
|
||||||
|
'range',
|
||||||
|
'reset',
|
||||||
|
'submit',
|
||||||
|
'time',
|
||||||
|
'week'
|
||||||
|
]);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-native-context-menu',
|
selector: 'app-native-context-menu',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -18,8 +53,29 @@ import type { ContextMenuParams } from '../../../core/platform/electron/electron
|
|||||||
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||||
params = signal<ContextMenuParams | null>(null);
|
params = signal<ContextMenuParams | null>(null);
|
||||||
|
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
private cleanup: (() => void) | null = null;
|
private cleanup: (() => void) | null = null;
|
||||||
|
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
||||||
|
|
||||||
|
@HostListener('document:contextmenu', ['$event'])
|
||||||
|
onDocumentContextMenu(event: MouseEvent): void {
|
||||||
|
this.captureSelectionSnapshot(event);
|
||||||
|
|
||||||
|
if (this.electronBridge.isAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = this.buildBrowserContextMenuParams(event);
|
||||||
|
|
||||||
|
if (!params) {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this.params.set(params);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
@@ -34,7 +90,12 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
|||||||
|| !!incoming.linkURL
|
|| !!incoming.linkURL
|
||||||
|| (incoming.mediaType === 'image' && !!incoming.srcURL);
|
|| (incoming.mediaType === 'image' && !!incoming.srcURL);
|
||||||
|
|
||||||
this.params.set(hasContent ? incoming : null);
|
if (!hasContent) {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.params.set(incoming);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,36 +106,514 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.params.set(null);
|
this.params.set(null);
|
||||||
|
this.selectionSnapshot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionPointerDown(event: PointerEvent, action: ContextMenuAction): void {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
void this.runAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionClick(event: MouseEvent, action: ContextMenuAction): void {
|
||||||
|
if (event.detail > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
void this.runAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runAction(action: ContextMenuAction): Promise<void> {
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case 'copyLink':
|
||||||
|
await this.copyLink();
|
||||||
|
break;
|
||||||
|
case 'copyImage':
|
||||||
|
await this.copyImage();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await this.execCommand(action);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execCommand(command: ContextMenuCommand): Promise<void> {
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'copy':
|
||||||
|
handled = await this.copySelection();
|
||||||
|
break;
|
||||||
|
case 'cut':
|
||||||
|
handled = await this.cutSelection();
|
||||||
|
break;
|
||||||
|
case 'paste':
|
||||||
|
handled = await this.pasteSelection();
|
||||||
|
break;
|
||||||
|
case 'selectAll':
|
||||||
|
handled = this.selectAllSelection();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
execCommand(command: string): void {
|
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
if (api?.contextMenuCommand) {
|
if (api?.contextMenuCommand) {
|
||||||
api.contextMenuCommand(command);
|
this.restoreSelectionSnapshot();
|
||||||
|
await api.contextMenuCommand(command);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.close();
|
private async copyLink(): Promise<void> {
|
||||||
}
|
|
||||||
|
|
||||||
copyLink(): void {
|
|
||||||
const url = this.params()?.linkURL;
|
const url = this.params()?.linkURL;
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
navigator.clipboard.writeText(url).catch(() => {});
|
await this.writeTextToClipboard(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.close();
|
private async copyImage(): Promise<void> {
|
||||||
}
|
|
||||||
|
|
||||||
copyImage(): void {
|
|
||||||
const srcURL = this.params()?.srcURL;
|
const srcURL = this.params()?.srcURL;
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
if (srcURL && api?.copyImageToClipboard) {
|
if (!srcURL) {
|
||||||
api.copyImageToClipboard(srcURL).catch(() => {});
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.close();
|
if (api?.copyImageToClipboard) {
|
||||||
|
const copied = await api.copyImageToClipboard(srcURL).catch(() => false);
|
||||||
|
|
||||||
|
if (copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.copyImageToBrowserClipboard(srcURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private captureSelectionSnapshot(event: MouseEvent): void {
|
||||||
|
const target = this.getTargetElement(event.target);
|
||||||
|
const textControl = this.resolveTextControlTarget(target);
|
||||||
|
|
||||||
|
if (textControl) {
|
||||||
|
this.selectionSnapshot = this.createTextControlSnapshot(textControl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = this.document.getSelection();
|
||||||
|
|
||||||
|
this.selectionSnapshot = {
|
||||||
|
range: selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null,
|
||||||
|
selectedText: selection?.toString() ?? '',
|
||||||
|
selectionDirection: null,
|
||||||
|
selectionEnd: null,
|
||||||
|
selectionStart: null,
|
||||||
|
target: this.resolveContentEditableTarget(target) ?? (target instanceof HTMLElement ? target : null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTextControlSnapshot(target: TextControlElement): ContextMenuSelectionSnapshot {
|
||||||
|
return {
|
||||||
|
range: null,
|
||||||
|
selectedText: this.getTextControlSelection(target),
|
||||||
|
selectionDirection: target.selectionDirection,
|
||||||
|
selectionEnd: target.selectionEnd,
|
||||||
|
selectionStart: target.selectionStart,
|
||||||
|
target
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBrowserContextMenuParams(event: MouseEvent): ContextMenuParams | null {
|
||||||
|
const target = this.getTargetElement(event.target);
|
||||||
|
const editableTarget = this.resolveEditableTarget(target);
|
||||||
|
const selectionText = this.selectionSnapshot?.selectedText ?? '';
|
||||||
|
const linkURL = this.resolveLinkUrl(target);
|
||||||
|
const srcURL = this.resolveImageUrl(target);
|
||||||
|
const isEditable = !!editableTarget && !this.isDisabledTarget(editableTarget);
|
||||||
|
|
||||||
|
if (!isEditable && !selectionText && !linkURL && !srcURL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
posX: event.clientX,
|
||||||
|
posY: event.clientY,
|
||||||
|
isEditable,
|
||||||
|
selectionText,
|
||||||
|
linkURL,
|
||||||
|
mediaType: srcURL ? 'image' : '',
|
||||||
|
srcURL,
|
||||||
|
editFlags: {
|
||||||
|
canCut: !!selectionText && !!editableTarget && this.canWriteToTarget(editableTarget),
|
||||||
|
canCopy: !!selectionText,
|
||||||
|
canPaste: !!editableTarget && this.canWriteToTarget(editableTarget) && this.canReadClipboard(),
|
||||||
|
canSelectAll: !!editableTarget && !this.isDisabledTarget(editableTarget)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreSelectionSnapshot(): ContextMenuTarget | null {
|
||||||
|
const snapshot = this.selectionSnapshot;
|
||||||
|
|
||||||
|
if (!snapshot?.target || !snapshot.target.isConnected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isTextControl(snapshot.target)) {
|
||||||
|
snapshot.target.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
if (snapshot.selectionStart !== null && snapshot.selectionEnd !== null) {
|
||||||
|
snapshot.target.setSelectionRange(
|
||||||
|
snapshot.selectionStart,
|
||||||
|
snapshot.selectionEnd,
|
||||||
|
snapshot.selectionDirection ?? undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isContentEditableTarget(snapshot.target)) {
|
||||||
|
snapshot.target.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = this.document.getSelection();
|
||||||
|
|
||||||
|
if (selection && snapshot.range) {
|
||||||
|
try {
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(snapshot.range);
|
||||||
|
} catch {
|
||||||
|
return snapshot.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copySelection(): Promise<boolean> {
|
||||||
|
const text = this.selectionSnapshot?.selectedText ?? '';
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.writeTextToClipboard(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cutSelection(): Promise<boolean> {
|
||||||
|
const target = this.restoreSelectionSnapshot();
|
||||||
|
|
||||||
|
if (!target || !this.canWriteToTarget(target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = this.selectionSnapshot?.selectedText ?? '';
|
||||||
|
|
||||||
|
if (!text || !(await this.writeTextToClipboard(text))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isTextControl(target)) {
|
||||||
|
const selectionStart = target.selectionStart ?? this.selectionSnapshot?.selectionStart ?? 0;
|
||||||
|
const selectionEnd = target.selectionEnd ?? this.selectionSnapshot?.selectionEnd ?? selectionStart;
|
||||||
|
|
||||||
|
target.setRangeText('', selectionStart, selectionEnd, 'start');
|
||||||
|
this.dispatchInputEvent(target);
|
||||||
|
this.selectionSnapshot = this.createTextControlSnapshot(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = this.document.getSelection();
|
||||||
|
|
||||||
|
if (!selection?.rangeCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
range.deleteContents();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
this.dispatchInputEvent(target);
|
||||||
|
this.selectionSnapshot = {
|
||||||
|
range: range.cloneRange(),
|
||||||
|
selectedText: '',
|
||||||
|
selectionDirection: null,
|
||||||
|
selectionEnd: null,
|
||||||
|
selectionStart: null,
|
||||||
|
target
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pasteSelection(): Promise<boolean> {
|
||||||
|
const target = this.restoreSelectionSnapshot();
|
||||||
|
|
||||||
|
if (!target || !this.canWriteToTarget(target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await this.readClipboardText();
|
||||||
|
|
||||||
|
if (text === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isTextControl(target)) {
|
||||||
|
const selectionStart = target.selectionStart ?? target.value.length;
|
||||||
|
const selectionEnd = target.selectionEnd ?? selectionStart;
|
||||||
|
|
||||||
|
target.setRangeText(text, selectionStart, selectionEnd, 'end');
|
||||||
|
this.dispatchInputEvent(target);
|
||||||
|
this.selectionSnapshot = this.createTextControlSnapshot(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = this.document.getSelection();
|
||||||
|
|
||||||
|
if (!selection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selection.rangeCount) {
|
||||||
|
const range = this.document.createRange();
|
||||||
|
|
||||||
|
range.selectNodeContents(target);
|
||||||
|
range.collapse(false);
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const textNode = this.document.createTextNode(text);
|
||||||
|
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(textNode);
|
||||||
|
range.setStartAfter(textNode);
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
this.dispatchInputEvent(target);
|
||||||
|
this.selectionSnapshot = {
|
||||||
|
range: range.cloneRange(),
|
||||||
|
selectedText: '',
|
||||||
|
selectionDirection: null,
|
||||||
|
selectionEnd: null,
|
||||||
|
selectionStart: null,
|
||||||
|
target
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectAllSelection(): boolean {
|
||||||
|
const target = this.selectionSnapshot?.target;
|
||||||
|
|
||||||
|
if (!target || !target.isConnected || this.isDisabledTarget(target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isTextControl(target)) {
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
target.select();
|
||||||
|
this.selectionSnapshot = this.createTextControlSnapshot(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isContentEditableTarget(target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = this.document.getSelection();
|
||||||
|
|
||||||
|
if (!selection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = this.document.createRange();
|
||||||
|
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
range.selectNodeContents(target);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
this.selectionSnapshot = {
|
||||||
|
range: range.cloneRange(),
|
||||||
|
selectedText: selection.toString(),
|
||||||
|
selectionDirection: null,
|
||||||
|
selectionEnd: null,
|
||||||
|
selectionStart: null,
|
||||||
|
target
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeTextToClipboard(value: string): Promise<boolean> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = this.document.body;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = this.document.createElement('textarea');
|
||||||
|
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.setAttribute('readonly', 'true');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return this.document.execCommand('copy');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readClipboardText(): Promise<string | null> {
|
||||||
|
if (navigator.clipboard?.readText) {
|
||||||
|
try {
|
||||||
|
return await navigator.clipboard.readText();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyImageToBrowserClipboard(srcURL: string): Promise<boolean> {
|
||||||
|
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(srcURL);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
if (!blob.type.startsWith('image/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
[blob.type || 'image/png']: blob
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchInputEvent(target: ContextMenuTarget): void {
|
||||||
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTextControlSelection(target: TextControlElement): string {
|
||||||
|
const selectionStart = target.selectionStart ?? 0;
|
||||||
|
const selectionEnd = target.selectionEnd ?? selectionStart;
|
||||||
|
|
||||||
|
return target.value.slice(selectionStart, selectionEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTargetElement(target: EventTarget | null): Element | null {
|
||||||
|
if (target instanceof Element) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target instanceof Node ? target.parentElement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveEditableTarget(target: Element | null): ContextMenuTarget | null {
|
||||||
|
return this.resolveTextControlTarget(target) ?? this.resolveContentEditableTarget(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTextControlTarget(target: Element | null): TextControlElement | null {
|
||||||
|
const textControl = target?.closest('input, textarea');
|
||||||
|
|
||||||
|
if (textControl instanceof HTMLTextAreaElement) {
|
||||||
|
return textControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textControl instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(textControl.type)) {
|
||||||
|
return textControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContentEditableTarget(target: Element | null): HTMLElement | null {
|
||||||
|
const editable = target?.closest('[contenteditable]:not([contenteditable="false"])');
|
||||||
|
|
||||||
|
return editable instanceof HTMLElement && editable.isContentEditable ? editable : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLinkUrl(target: Element | null): string {
|
||||||
|
const link = target?.closest('a[href]');
|
||||||
|
|
||||||
|
return link instanceof HTMLAnchorElement ? link.href : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveImageUrl(target: Element | null): string {
|
||||||
|
const imageTarget = target instanceof HTMLImageElement
|
||||||
|
? target
|
||||||
|
: target?.closest('img');
|
||||||
|
|
||||||
|
return imageTarget instanceof HTMLImageElement
|
||||||
|
? imageTarget.currentSrc || imageTarget.src
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTextControl(target: ContextMenuTarget | null): target is TextControlElement {
|
||||||
|
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isContentEditableTarget(target: ContextMenuTarget | null): target is HTMLElement {
|
||||||
|
return target instanceof HTMLElement && target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDisabledTarget(target: ContextMenuTarget | null): boolean {
|
||||||
|
return this.isTextControl(target) ? target.disabled : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canWriteToTarget(target: ContextMenuTarget | null): boolean {
|
||||||
|
if (this.isTextControl(target)) {
|
||||||
|
return !target.disabled && !target.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isContentEditableTarget(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private canReadClipboard(): boolean {
|
||||||
|
return typeof navigator !== 'undefined'
|
||||||
|
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
name="lucideRefreshCw"
|
name="lucideRefreshCw"
|
||||||
class="h-3.5 w-3.5 animate-spin"
|
class="h-3.5 w-3.5 animate-spin"
|
||||||
/>
|
/>
|
||||||
Reconnecting to signal server…
|
Reconnecting to signal server...
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||||
[class.hidden]="!isReconnecting()"
|
[class.hidden]="!isReconnecting()"
|
||||||
>Reconnecting…</span
|
>Reconnecting...</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
@if (creatingInvite()) {
|
@if (creatingInvite()) {
|
||||||
Creating Invite Link…
|
Creating Invite Link...
|
||||||
} @else {
|
} @else {
|
||||||
Create Invite Link
|
Create Invite Link
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export class TitleBarComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.creatingInvite.set(true);
|
this.creatingInvite.set(true);
|
||||||
this.inviteStatus.set('Creating invite link…');
|
this.inviteStatus.set('Creating invite link...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Realtime Infrastructure
|
# Realtime Infrastructure
|
||||||
|
|
||||||
Low-level WebRTC and WebSocket plumbing that the rest of the app sits on top of. Nothing in here knows about Angular components, NgRx, or domain logic. It exposes observables, signals, and callbacks that higher layers (facades, effects, components) consume.
|
Low-level WebRTC and WebSocket plumbing plus the Angular-facing runtime boundary that the rest of the app sits on top of. Most files here stay technical and framework-light, but this area does use Angular signals and DI, shared-kernel contracts, and a small screen-share domain adapter at the composition boundary. It exposes observables, signals, and callbacks that higher layers (facades, effects, components) consume.
|
||||||
|
|
||||||
## Module map
|
## Module map
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ realtime/
|
|||||||
├── realtime-session.service.ts Composition root (WebRTCService)
|
├── realtime-session.service.ts Composition root (WebRTCService)
|
||||||
├── realtime.types.ts PeerData, credentials, tracker types
|
├── realtime.types.ts PeerData, credentials, tracker types
|
||||||
├── realtime.constants.ts ICE servers, signal types, bitrates, intervals
|
├── realtime.constants.ts ICE servers, signal types, bitrates, intervals
|
||||||
|
├── ice-server-settings.service.ts Persisted STUN/TURN configuration
|
||||||
|
├── screen-share.config.ts Shared screen-share options and presets
|
||||||
│
|
│
|
||||||
├── signaling/ WebSocket layer
|
├── signaling/ WebSocket layer
|
||||||
│ ├── signaling.manager.ts One WebSocket per signaling URL
|
│ ├── signaling.manager.ts One WebSocket per signaling URL
|
||||||
@@ -56,7 +58,7 @@ realtime/
|
|||||||
|
|
||||||
## How it all fits together
|
## How it all fits together
|
||||||
|
|
||||||
`WebRTCService` is the composition root. It instantiates every other manager, then wires their callbacks together after construction (to avoid circular references). No manager imports another manager directly.
|
`WebRTCService` is the composition root. It instantiates the main managers, then wires their callbacks together after construction to avoid the old monolithic tangle and break circular initialization. Some focused helpers still depend on other managers or their types, but cross-cutting orchestration stays centralized here instead of being spread across the runtime.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
@@ -232,7 +234,7 @@ A single ordered data channel carries all peer-to-peer messages: chat events, at
|
|||||||
|
|
||||||
Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers.
|
Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers.
|
||||||
|
|
||||||
Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces `avatarUpdatedAt`, receiver requests only when remote version is newer, then sender streams ordered base64 chunks over buffered sends.
|
Profile avatar sync follows attachment-style chunk transport plus server-icon-style version handshakes: sender announces avatar/profile versions, receiver requests only when either remote version is newer, then sender streams ordered base64 chunks when avatar bytes are needed and still uses the same full-event path for profile-only updates.
|
||||||
|
|
||||||
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
|
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
|
||||||
|
|
||||||
@@ -378,9 +380,10 @@ Instead of connecting peers directly:
|
|||||||
|
|
||||||
This approach is more reliable in restrictive network environments but introduces additional latency and bandwidth overhead, since all traffic flows through the relay instead of directly between peers.
|
This approach is more reliable in restrictive network environments but introduces additional latency and bandwidth overhead, since all traffic flows through the relay instead of directly between peers.
|
||||||
|
|
||||||
Toju/Zoracord does not use TURN and does not have code written to support it.
|
MetoYou ships with STUN-only defaults in `ICE_SERVERS`, but the runtime does support TURN entries through `IceServerSettingsService` and standard `RTCIceServer` credentials. There is no bundled TURN service or default TURN configuration in the repo.
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|
||||||
- **ICE** coordinates connection establishment by trying multiple network paths
|
- **ICE** coordinates connection establishment by trying multiple network paths
|
||||||
- **STUN** provides public-facing address discovery for NAT traversal
|
- **STUN** provides public-facing address discovery for NAT traversal
|
||||||
|
- **TURN** is an optional relay fallback that this runtime can be configured to use, but it is not bundled or enabled by default
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
type Signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants';
|
||||||
|
import { ICE_SERVERS } from './realtime.constants';
|
||||||
|
|
||||||
|
export interface IceServerEntry {
|
||||||
|
id: string;
|
||||||
|
type: 'stun' | 'turn';
|
||||||
|
urls: string;
|
||||||
|
username?: string;
|
||||||
|
credential?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ENTRIES: IceServerEntry[] = ICE_SERVERS.map((server, index) => ({
|
||||||
|
id: `default-stun-${index}`,
|
||||||
|
type: 'stun' as const,
|
||||||
|
urls: Array.isArray(server.urls) ? server.urls[0] : server.urls
|
||||||
|
}));
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class IceServerSettingsService {
|
||||||
|
readonly entries: Signal<IceServerEntry[]>;
|
||||||
|
readonly rtcIceServers: Signal<RTCIceServer[]>;
|
||||||
|
|
||||||
|
private readonly _entries = signal<IceServerEntry[]>(this.load());
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.entries = this._entries.asReadonly();
|
||||||
|
this.rtcIceServers = computed<RTCIceServer[]>(() =>
|
||||||
|
this._entries().map((entry) => {
|
||||||
|
if (entry.type === 'turn') {
|
||||||
|
return {
|
||||||
|
urls: entry.urls,
|
||||||
|
username: entry.username ?? '',
|
||||||
|
credential: entry.credential ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { urls: entry.urls };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEntry(entry: Omit<IceServerEntry, 'id'>): void {
|
||||||
|
const id = `${entry.type}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
const updated = [...this._entries(), { ...entry, id }];
|
||||||
|
|
||||||
|
this._entries.set(updated);
|
||||||
|
this.save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEntry(id: string): void {
|
||||||
|
const updated = this._entries().filter((entry) => entry.id !== id);
|
||||||
|
|
||||||
|
this._entries.set(updated);
|
||||||
|
this.save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntry(id: string, changes: Partial<Omit<IceServerEntry, 'id'>>): void {
|
||||||
|
const updated = this._entries().map((entry) =>
|
||||||
|
entry.id === id ? { ...entry, ...changes } : entry
|
||||||
|
);
|
||||||
|
|
||||||
|
this._entries.set(updated);
|
||||||
|
this.save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveEntry(fromIndex: number, toIndex: number): void {
|
||||||
|
const entries = [...this._entries()];
|
||||||
|
|
||||||
|
if (fromIndex < 0 || fromIndex >= entries.length || toIndex < 0 || toIndex >= entries.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [moved] = entries.splice(fromIndex, 1);
|
||||||
|
|
||||||
|
entries.splice(toIndex, 0, moved);
|
||||||
|
this._entries.set(entries);
|
||||||
|
this.save(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDefaults(): void {
|
||||||
|
this._entries.set([...DEFAULT_ENTRIES]);
|
||||||
|
this.save(DEFAULT_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): IceServerEntry[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY_ICE_SERVERS);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return [...DEFAULT_ENTRIES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
return [...DEFAULT_ENTRIES];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.filter(
|
||||||
|
(entry: unknown): entry is IceServerEntry =>
|
||||||
|
typeof entry === 'object'
|
||||||
|
&& entry !== null
|
||||||
|
&& typeof (entry as IceServerEntry).id === 'string'
|
||||||
|
&& ((entry as IceServerEntry).type === 'stun' || (entry as IceServerEntry).type === 'turn')
|
||||||
|
&& typeof (entry as IceServerEntry).urls === 'string'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [...DEFAULT_ENTRIES];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(entries: IceServerEntry[]): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY_ICE_SERVERS, JSON.stringify(entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { LatencyProfile } from '../realtime.constants';
|
|||||||
import { PeerData } from '../realtime.types';
|
import { PeerData } from '../realtime.types';
|
||||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||||
import { NoiseReductionManager } from './noise-reduction.manager';
|
import { NoiseReductionManager } from './noise-reduction.manager';
|
||||||
|
import { loadVoiceSettingsFromStorage } from '../../../domains/voice-session/infrastructure/util/voice-settings-storage.util';
|
||||||
import {
|
import {
|
||||||
TRACK_KIND_AUDIO,
|
TRACK_KIND_AUDIO,
|
||||||
TRACK_KIND_VIDEO,
|
TRACK_KIND_VIDEO,
|
||||||
@@ -105,6 +106,12 @@ export class MediaManager {
|
|||||||
private callbacks: MediaManagerCallbacks
|
private callbacks: MediaManagerCallbacks
|
||||||
) {
|
) {
|
||||||
this.noiseReduction = new NoiseReductionManager(logger);
|
this.noiseReduction = new NoiseReductionManager(logger);
|
||||||
|
|
||||||
|
// Read the persisted noise-reduction preference so enableVoice()
|
||||||
|
// uses the correct value even before voice-controls loads.
|
||||||
|
try {
|
||||||
|
this._noiseReductionDesired = loadVoiceSettingsFromStorage().noiseReduction;
|
||||||
|
} catch { /* keep default */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,7 +233,7 @@ export class MediaManager {
|
|||||||
: stream;
|
: stream;
|
||||||
|
|
||||||
// Apply input gain (mic volume) before sending to peers
|
// Apply input gain (mic volume) before sending to peers
|
||||||
this.applyInputGainToCurrentStream();
|
await this.applyInputGainToCurrentStream();
|
||||||
|
|
||||||
this.logger.logStream('localVoice', this.localMediaStream);
|
this.logger.logStream('localVoice', this.localMediaStream);
|
||||||
|
|
||||||
@@ -296,7 +303,7 @@ export class MediaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply input gain (mic volume) before sending to peers
|
// Apply input gain (mic volume) before sending to peers
|
||||||
this.applyInputGainToCurrentStream();
|
await this.applyInputGainToCurrentStream();
|
||||||
|
|
||||||
this.bindLocalTracksToAllPeers();
|
this.bindLocalTracksToAllPeers();
|
||||||
this.isVoiceActive = true;
|
this.isVoiceActive = true;
|
||||||
@@ -447,7 +454,7 @@ export class MediaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-apply input gain to the (possibly new) stream
|
// Re-apply input gain to the (possibly new) stream
|
||||||
this.applyInputGainToCurrentStream();
|
await this.applyInputGainToCurrentStream();
|
||||||
|
|
||||||
// Propagate the new audio track to every peer connection
|
// Propagate the new audio track to every peer connection
|
||||||
this.bindLocalTracksToAllPeers();
|
this.bindLocalTracksToAllPeers();
|
||||||
@@ -479,8 +486,7 @@ export class MediaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.localMediaStream) {
|
if (this.localMediaStream) {
|
||||||
this.applyInputGainToCurrentStream();
|
void this.applyInputGainToCurrentStream().then(() => this.bindLocalTracksToAllPeers());
|
||||||
this.bindLocalTracksToAllPeers();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,12 +846,22 @@ export class MediaManager {
|
|||||||
* If a gain pipeline already exists for the same source stream the gain
|
* If a gain pipeline already exists for the same source stream the gain
|
||||||
* value is simply updated. Otherwise a new pipeline is created.
|
* value is simply updated. Otherwise a new pipeline is created.
|
||||||
*/
|
*/
|
||||||
private applyInputGainToCurrentStream(): void {
|
private async applyInputGainToCurrentStream(): Promise<void> {
|
||||||
const stream = this.localMediaStream;
|
const stream = this.localMediaStream;
|
||||||
|
|
||||||
if (!stream)
|
if (!stream)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// When gain is unity (1.0) skip the Web Audio pipeline entirely and
|
||||||
|
// use the raw microphone stream. This avoids unnecessary AudioContext
|
||||||
|
// overhead when no volume adjustment is needed.
|
||||||
|
if (this.inputGainVolume === 1.0) {
|
||||||
|
this.teardownInputGain();
|
||||||
|
this.preGainStream = stream;
|
||||||
|
this.applyCurrentMuteState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the source stream hasn't changed, just update gain
|
// If the source stream hasn't changed, just update gain
|
||||||
if (this.preGainStream === stream && this.inputGainNode && this.inputGainCtx) {
|
if (this.preGainStream === stream && this.inputGainNode && this.inputGainCtx) {
|
||||||
this.inputGainNode.gain.value = this.inputGainVolume;
|
this.inputGainNode.gain.value = this.inputGainVolume;
|
||||||
@@ -855,9 +871,15 @@ export class MediaManager {
|
|||||||
// Tear down the old pipeline (if any)
|
// Tear down the old pipeline (if any)
|
||||||
this.teardownInputGain();
|
this.teardownInputGain();
|
||||||
|
|
||||||
// Build new pipeline: source → gain → destination
|
// Build new pipeline: source -> gain -> destination
|
||||||
this.preGainStream = stream;
|
this.preGainStream = stream;
|
||||||
this.inputGainCtx = new AudioContext();
|
this.inputGainCtx = new AudioContext();
|
||||||
|
|
||||||
|
// Ensure the AudioContext is running before connecting nodes.
|
||||||
|
if (this.inputGainCtx.state !== 'running') {
|
||||||
|
await this.inputGainCtx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
this.inputGainSourceNode = this.inputGainCtx.createMediaStreamSource(stream);
|
this.inputGainSourceNode = this.inputGainCtx.createMediaStreamSource(stream);
|
||||||
this.inputGainNode = this.inputGainCtx.createGain();
|
this.inputGainNode = this.inputGainCtx.createGain();
|
||||||
this.inputGainNode.gain.value = this.inputGainVolume;
|
this.inputGainNode.gain.value = this.inputGainVolume;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
* a clean output stream that can be sent to peers instead.
|
* a clean output stream that can be sent to peers instead.
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Architecture:
|
||||||
* raw mic → AudioContext.createMediaStreamSource
|
* raw mic -> AudioContext.createMediaStreamSource
|
||||||
* → NoiseSuppressorWorklet (AudioWorkletNode)
|
* -> NoiseSuppressorWorklet (AudioWorkletNode)
|
||||||
* → MediaStreamDestination → clean MediaStream
|
* -> MediaStreamDestination -> clean MediaStream
|
||||||
*
|
*
|
||||||
* The manager is intentionally stateless w.r.t. Angular signals;
|
* The manager is intentionally stateless w.r.t. Angular signals;
|
||||||
* the owning MediaManager / WebRTCService drives signals.
|
* the owning MediaManager / WebRTCService drives signals.
|
||||||
@@ -138,7 +138,7 @@ export class NoiseReductionManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the AudioWorklet processing graph:
|
* Build the AudioWorklet processing graph:
|
||||||
* rawStream → source → workletNode → destination
|
* rawStream -> source -> workletNode -> destination
|
||||||
*/
|
*/
|
||||||
private async buildProcessingGraph(rawStream: MediaStream): Promise<void> {
|
private async buildProcessingGraph(rawStream: MediaStream): Promise<void> {
|
||||||
// Reuse or create the AudioContext (must be 48 kHz for RNNoise)
|
// Reuse or create the AudioContext (must be 48 kHz for RNNoise)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
CONNECTION_STATE_DISCONNECTED,
|
CONNECTION_STATE_DISCONNECTED,
|
||||||
CONNECTION_STATE_FAILED,
|
CONNECTION_STATE_FAILED,
|
||||||
DATA_CHANNEL_LABEL,
|
DATA_CHANNEL_LABEL,
|
||||||
ICE_SERVERS,
|
|
||||||
SIGNALING_TYPE_ICE_CANDIDATE,
|
SIGNALING_TYPE_ICE_CANDIDATE,
|
||||||
TRACK_KIND_AUDIO,
|
TRACK_KIND_AUDIO,
|
||||||
TRACK_KIND_VIDEO,
|
TRACK_KIND_VIDEO,
|
||||||
@@ -28,7 +27,7 @@ export function createPeerConnection(
|
|||||||
|
|
||||||
logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
||||||
|
|
||||||
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
const connection = new RTCPeerConnection({ iceServers: callbacks.getIceServers() });
|
||||||
|
|
||||||
let dataChannel: RTCDataChannel | null = null;
|
let dataChannel: RTCDataChannel | null = null;
|
||||||
let peerData: PeerData | null = null;
|
let peerData: PeerData | null = null;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface PeerConnectionCallbacks {
|
|||||||
isScreenSharingActive(): boolean;
|
isScreenSharingActive(): boolean;
|
||||||
/** Whether the local camera is active. */
|
/** Whether the local camera is active. */
|
||||||
isCameraEnabled(): boolean;
|
isCameraEnabled(): boolean;
|
||||||
|
/** Returns the user-configured ICE servers for peer connection creation. */
|
||||||
|
getIceServers(): RTCIceServer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeerConnectionManagerState {
|
export interface PeerConnectionManagerState {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
* WebRTCService - thin Angular service that composes specialised managers.
|
* WebRTCService - thin Angular service that composes specialised managers.
|
||||||
*
|
*
|
||||||
* Each concern lives in its own file under `./`:
|
* Each concern lives in its own file under `./`:
|
||||||
* • SignalingManager - WebSocket lifecycle & reconnection
|
* - SignalingManager - WebSocket lifecycle & reconnection
|
||||||
* • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
|
* - PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
|
||||||
* • MediaManager - mic voice, mute, deafen, bitrate
|
* - MediaManager - mic voice, mute, deafen, bitrate
|
||||||
* • ScreenShareManager - screen capture & mixed audio
|
* - ScreenShareManager - screen capture & mixed audio
|
||||||
* • WebRTCLogger - debug / diagnostic logging
|
* - WebRTCLogger - debug / diagnostic logging
|
||||||
*
|
*
|
||||||
* This file wires them together and exposes a public API that is
|
* This file wires them together and exposes a public API that is
|
||||||
* identical to the old monolithic service so consumers don't change.
|
* identical to the old monolithic service so consumers don't change.
|
||||||
@@ -26,6 +26,7 @@ import { ScreenShareSourcePickerService } from '../../domains/screen-share';
|
|||||||
import { MediaManager } from './media/media.manager';
|
import { MediaManager } from './media/media.manager';
|
||||||
import { ScreenShareManager } from './media/screen-share.manager';
|
import { ScreenShareManager } from './media/screen-share.manager';
|
||||||
import { VoiceSessionController } from './media/voice-session-controller';
|
import { VoiceSessionController } from './media/voice-session-controller';
|
||||||
|
import { IceServerSettingsService } from './ice-server-settings.service';
|
||||||
import type { PeerData, VoiceStateSnapshot } from './realtime.types';
|
import type { PeerData, VoiceStateSnapshot } from './realtime.types';
|
||||||
import { LatencyProfile } from './realtime.constants';
|
import { LatencyProfile } from './realtime.constants';
|
||||||
import { ScreenShareStartOptions } from './screen-share.config';
|
import { ScreenShareStartOptions } from './screen-share.config';
|
||||||
@@ -47,6 +48,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private readonly timeSync = inject(TimeSyncService);
|
private readonly timeSync = inject(TimeSyncService);
|
||||||
private readonly debugging = inject(DebuggingService);
|
private readonly debugging = inject(DebuggingService);
|
||||||
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
||||||
|
private readonly iceServerSettings = inject(IceServerSettingsService);
|
||||||
|
|
||||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||||
private readonly state = new WebRtcStateController();
|
private readonly state = new WebRtcStateController();
|
||||||
@@ -151,7 +153,8 @@ export class WebRTCService implements OnDestroy {
|
|||||||
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
|
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
|
||||||
getLocalPeerId: (): string => this.state.getLocalPeerId(),
|
getLocalPeerId: (): string => this.state.getLocalPeerId(),
|
||||||
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
|
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
|
||||||
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive()
|
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive(),
|
||||||
|
getIceServers: (): RTCIceServer[] => this.iceServerSettings.rtcIceServers()
|
||||||
});
|
});
|
||||||
|
|
||||||
this.mediaManager.setCallbacks({
|
this.mediaManager.setCallbacks({
|
||||||
@@ -211,7 +214,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this.remoteScreenShareRequestController.handlePeerControlMessage(event)
|
this.remoteScreenShareRequestController.handlePeerControlMessage(event)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Peer manager → connected peers signal
|
// Peer manager -> connected peers signal
|
||||||
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
|
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
|
||||||
this.state.setConnectedPeers(peers)
|
this.state.setConnectedPeers(peers)
|
||||||
);
|
);
|
||||||
@@ -232,12 +235,12 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
|
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Media manager → voice connected signal
|
// Media manager -> voice connected signal
|
||||||
this.mediaManager.voiceConnected$.subscribe(() => {
|
this.mediaManager.voiceConnected$.subscribe(() => {
|
||||||
this.voiceSessionController.handleVoiceConnected();
|
this.voiceSessionController.handleVoiceConnected();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Peer manager → latency updates
|
// Peer manager -> latency updates
|
||||||
this.peerManager.peerLatencyChanged$.subscribe(() =>
|
this.peerManager.peerLatencyChanged$.subscribe(() =>
|
||||||
this.state.syncPeerLatencies(this.peerManager.peerLatencies)
|
this.state.syncPeerLatencies(this.peerManager.peerLatencies)
|
||||||
);
|
);
|
||||||
@@ -328,8 +331,13 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param oderId - The user's unique order/peer ID.
|
* @param oderId - The user's unique order/peer ID.
|
||||||
* @param displayName - The user's display name.
|
* @param displayName - The user's display name.
|
||||||
*/
|
*/
|
||||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
identify(
|
||||||
this.signalingTransportHandler.identify(oderId, displayName, signalUrl);
|
oderId: string,
|
||||||
|
displayName: string,
|
||||||
|
signalUrl?: string,
|
||||||
|
profile?: { description?: string; profileUpdatedAt?: number }
|
||||||
|
): void {
|
||||||
|
this.signalingTransportHandler.identify(oderId, displayName, signalUrl, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -487,6 +495,14 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return this.peerMediaFacade.getRawMicStream();
|
return this.peerMediaFacade.getRawMicStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportConnectionError(message: string): void {
|
||||||
|
this.state.setConnectionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearConnectionError(): void {
|
||||||
|
this.state.clearConnectionError();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request microphone access and start sending audio to all peers.
|
* Request microphone access and start sending audio to all peers.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../shared-kernel';
|
|||||||
export const AUDIO_BITRATE_MIN_BPS = 16_000;
|
export const AUDIO_BITRATE_MIN_BPS = 16_000;
|
||||||
/** Maximum audio bitrate (bps) */
|
/** Maximum audio bitrate (bps) */
|
||||||
export const AUDIO_BITRATE_MAX_BPS = 256_000;
|
export const AUDIO_BITRATE_MAX_BPS = 256_000;
|
||||||
/** Multiplier to convert kbps → bps */
|
/** Multiplier to convert kbps -> bps */
|
||||||
export const KBPS_TO_BPS = 1_000;
|
export const KBPS_TO_BPS = 1_000;
|
||||||
/** Pre-defined latency-to-bitrate mappings (bps) */
|
/** Pre-defined latency-to-bitrate mappings (bps) */
|
||||||
export const LATENCY_PROFILE_BITRATES: Record<LatencyProfile, number> = {
|
export const LATENCY_PROFILE_BITRATES: Record<LatencyProfile, number> = {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface IdentifyCredentials {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
/** The user's display name shown to other peers. */
|
/** The user's display name shown to other peers. */
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Optional profile description advertised via signaling identity. */
|
||||||
|
description?: string;
|
||||||
|
/** Monotonic profile version for late-join reconciliation. */
|
||||||
|
profileUpdatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Last-joined server info, used for reconnection. */
|
/** Last-joined server info, used for reconnection. */
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
|
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getIdentifyDescription(): string | undefined {
|
||||||
|
return this.lastIdentifyCredentials?.description;
|
||||||
|
}
|
||||||
|
|
||||||
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
|
||||||
return this.dependencies.signalingCoordinator.getConnectedSignalingManagers();
|
return this.dependencies.signalingCoordinator.getConnectedSignalingManagers();
|
||||||
}
|
}
|
||||||
@@ -160,12 +164,27 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
identify(
|
||||||
|
oderId: string,
|
||||||
|
displayName: string,
|
||||||
|
signalUrl?: string,
|
||||||
|
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt'>
|
||||||
|
): void {
|
||||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||||
|
const normalizedDescription = typeof profile?.description === 'string'
|
||||||
|
? (profile.description.trim() || undefined)
|
||||||
|
: undefined;
|
||||||
|
const normalizedProfileUpdatedAt = typeof profile?.profileUpdatedAt === 'number'
|
||||||
|
&& Number.isFinite(profile.profileUpdatedAt)
|
||||||
|
&& profile.profileUpdatedAt > 0
|
||||||
|
? profile.profileUpdatedAt
|
||||||
|
: undefined;
|
||||||
|
|
||||||
this.lastIdentifyCredentials = {
|
this.lastIdentifyCredentials = {
|
||||||
oderId,
|
oderId,
|
||||||
displayName: normalizedDisplayName
|
displayName: normalizedDisplayName,
|
||||||
|
description: normalizedDescription,
|
||||||
|
profileUpdatedAt: normalizedProfileUpdatedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signalUrl) {
|
if (signalUrl) {
|
||||||
@@ -173,6 +192,8 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
type: SIGNALING_TYPE_IDENTIFY,
|
type: SIGNALING_TYPE_IDENTIFY,
|
||||||
oderId,
|
oderId,
|
||||||
displayName: normalizedDisplayName,
|
displayName: normalizedDisplayName,
|
||||||
|
description: normalizedDescription,
|
||||||
|
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||||
connectionScope: signalUrl
|
connectionScope: signalUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,6 +211,8 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
type: SIGNALING_TYPE_IDENTIFY,
|
type: SIGNALING_TYPE_IDENTIFY,
|
||||||
oderId,
|
oderId,
|
||||||
displayName: normalizedDisplayName,
|
displayName: normalizedDisplayName,
|
||||||
|
description: normalizedDescription,
|
||||||
|
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||||
connectionScope: managerSignalUrl
|
connectionScope: managerSignalUrl
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,16 @@ export class WebRtcStateController {
|
|||||||
this._isNoiseReductionEnabled.set(enabled);
|
this._isNoiseReductionEnabled.set(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setConnectionError(message: string | null): void {
|
||||||
|
this._hasConnectionError.set(!!message);
|
||||||
|
this._connectionErrorMessage.set(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearConnectionError(): void {
|
||||||
|
this._hasConnectionError.set(false);
|
||||||
|
this._connectionErrorMessage.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
setConnectedPeers(peers: string[]): void {
|
setConnectedPeers(peers: string[]): void {
|
||||||
this._connectedPeers.set(peers);
|
this._connectedPeers.set(peers);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface ChatEventBase {
|
|||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
settings?: Partial<RoomSettings>;
|
settings?: Partial<RoomSettings>;
|
||||||
@@ -273,6 +275,8 @@ export interface UserAvatarSummaryEvent extends ChatEventBase {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
avatarUpdatedAt: number;
|
avatarUpdatedAt: number;
|
||||||
@@ -288,8 +292,10 @@ export interface UserAvatarFullEvent extends ChatEventBase {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime: string;
|
avatarMime?: string;
|
||||||
avatarUpdatedAt: number;
|
avatarUpdatedAt: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface User {
|
|||||||
oderId: string;
|
oderId: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
@@ -35,6 +37,8 @@ export interface RoomMember {
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarHash?: string;
|
avatarHash?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
@if (waveformLoading()) {
|
@if (waveformLoading()) {
|
||||||
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform…</div>
|
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform...</div>
|
||||||
} @else if (waveformUnavailable()) {
|
} @else if (waveformUnavailable()) {
|
||||||
<div class="audio-waveform-overlay text-muted-foreground">
|
<div class="audio-waveform-overlay text-muted-foreground">
|
||||||
Couldn’t render a waveform preview for this file, but playback still works.
|
Couldn’t render a waveform preview for this file, but playback still works.
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export class DebugConsoleNetworkMapComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formatEdgeHeading(edge: DebugNetworkEdge): string {
|
formatEdgeHeading(edge: DebugNetworkEdge): string {
|
||||||
return `${edge.sourceLabel} → ${edge.targetLabel}`;
|
return `${edge.sourceLabel} -> ${edge.targetLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatMessageGroup(group: DebugNetworkMessageGroup): string {
|
formatMessageGroup(group: DebugNetworkMessageGroup): string {
|
||||||
@@ -646,6 +646,6 @@ export class DebugConsoleNetworkMapComponent implements OnDestroy {
|
|||||||
if (value.length <= 18)
|
if (value.length <= 18)
|
||||||
return value;
|
return value;
|
||||||
|
|
||||||
return `${value.slice(0, 8)}…${value.slice(-6)}`;
|
return `${value.slice(0, 8)}...${value.slice(-6)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export class DebugConsoleExportService {
|
|||||||
this.escapeCsvField(edge.sourceLabel),
|
this.escapeCsvField(edge.sourceLabel),
|
||||||
this.escapeCsvField(edge.targetLabel),
|
this.escapeCsvField(edge.targetLabel),
|
||||||
edge.kind,
|
edge.kind,
|
||||||
`${edge.sourceLabel} → ${edge.targetLabel}`,
|
`${edge.sourceLabel} -> ${edge.targetLabel}`,
|
||||||
edge.isActive
|
edge.isActive
|
||||||
].join(',')
|
].join(',')
|
||||||
);
|
);
|
||||||
@@ -353,7 +353,7 @@ export class DebugConsoleExportService {
|
|||||||
private appendEdgeTxt(lines: string[], edge: DebugNetworkEdge): void {
|
private appendEdgeTxt(lines: string[], edge: DebugNetworkEdge): void {
|
||||||
const activeLabel = edge.isActive ? 'active' : 'inactive';
|
const activeLabel = edge.isActive ? 'active' : 'inactive';
|
||||||
|
|
||||||
lines.push(` [${edge.kind}] ${edge.sourceLabel} → ${edge.targetLabel} (${activeLabel})`);
|
lines.push(` [${edge.kind}] ${edge.sourceLabel} -> ${edge.targetLabel} (${activeLabel})`);
|
||||||
|
|
||||||
if (edge.pingMs !== null)
|
if (edge.pingMs !== null)
|
||||||
lines.push(` Ping: ${edge.pingMs} ms`);
|
lines.push(` Ping: ${edge.pingMs} ms`);
|
||||||
@@ -391,7 +391,7 @@ export class DebugConsoleExportService {
|
|||||||
for (const edge of outgoing) {
|
for (const edge of outgoing) {
|
||||||
const target = nodeMap.get(edge.targetId);
|
const target = nodeMap.get(edge.targetId);
|
||||||
|
|
||||||
lines.push(` → ${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
|
lines.push(` -> ${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ export class DebugConsoleExportService {
|
|||||||
for (const edge of incoming) {
|
for (const edge of incoming) {
|
||||||
const source = nodeMap.get(edge.sourceId);
|
const source = nodeMap.get(edge.sourceId);
|
||||||
|
|
||||||
lines.push(` ← ${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
|
lines.push(` <- ${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,29 @@
|
|||||||
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||||
>
|
>
|
||||||
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
@let profileUser = user();
|
||||||
|
@let isEditable = editable();
|
||||||
|
@let activeField = editingField();
|
||||||
|
@let statusColor = currentStatusColor();
|
||||||
|
@let statusLabel = currentStatusLabel();
|
||||||
|
|
||||||
|
<div class="h-20 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||||
|
|
||||||
<div class="relative px-4">
|
<div class="relative px-4">
|
||||||
<div class="-mt-9">
|
<div class="-mt-8">
|
||||||
@if (editable()) {
|
|
||||||
<button
|
<button
|
||||||
#avatarInputButton
|
|
||||||
type="button"
|
type="button"
|
||||||
class="group relative rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
class="rounded-full"
|
||||||
(click)="pickAvatar(avatarInput)"
|
(click)="pickAvatar(avatarInput)"
|
||||||
>
|
>
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="user().displayName"
|
[name]="profileUser.displayName"
|
||||||
[avatarUrl]="user().avatarUrl"
|
[avatarUrl]="profileUser.avatarUrl"
|
||||||
size="xl"
|
size="xl"
|
||||||
[status]="user().status"
|
[status]="profileUser.status"
|
||||||
[showStatusBadge]="true"
|
[showStatusBadge]="true"
|
||||||
ringClass="ring-4 ring-card"
|
ringClass="ring-4 ring-card"
|
||||||
/>
|
/>
|
||||||
<span class="pointer-events-none absolute inset-0 rounded-full bg-black/0 transition-colors group-hover:bg-black/15"></span>
|
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
#avatarInput
|
#avatarInput
|
||||||
@@ -30,35 +33,76 @@
|
|||||||
[accept]="avatarAccept"
|
[accept]="avatarAccept"
|
||||||
(change)="onAvatarSelected($event)"
|
(change)="onAvatarSelected($event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
</div>
|
||||||
<app-user-avatar
|
</div>
|
||||||
[name]="user().displayName"
|
|
||||||
[avatarUrl]="user().avatarUrl"
|
<div class="px-4 pb-3 pt-2.5">
|
||||||
size="xl"
|
@if (isEditable) {
|
||||||
[status]="user().status"
|
<div class="space-y-2">
|
||||||
[showStatusBadge]="true"
|
<div>
|
||||||
ringClass="ring-4 ring-card"
|
@if (activeField === 'displayName') {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-md border border-border bg-background/70 px-2 py-1.5 text-base font-semibold text-foreground outline-none focus:border-primary/70"
|
||||||
|
[value]="displayNameDraft()"
|
||||||
|
(input)="onDisplayNameInput($event)"
|
||||||
|
(blur)="finishEdit('displayName')"
|
||||||
/>
|
/>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full py-0.5 text-left text-base font-semibold text-foreground"
|
||||||
|
(click)="startEdit('displayName')"
|
||||||
|
>
|
||||||
|
{{ profileUser.displayName }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@if (activeField === 'description') {
|
||||||
|
<textarea
|
||||||
|
rows="3"
|
||||||
|
class="w-full resize-none rounded-md border border-border bg-background/70 px-2 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
|
||||||
|
[value]="descriptionDraft()"
|
||||||
|
placeholder="Add a description"
|
||||||
|
(input)="onDescriptionInput($event)"
|
||||||
|
(blur)="finishEdit('description')"
|
||||||
|
></textarea>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full py-1 text-left text-sm leading-5"
|
||||||
|
(click)="startEdit('description')"
|
||||||
|
>
|
||||||
|
@if (profileUser.description) {
|
||||||
|
<span class="whitespace-pre-line text-muted-foreground">{{ profileUser.description }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted-foreground/70">Add a description</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p>
|
||||||
|
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||||
|
|
||||||
<div class="px-5 pb-4 pt-3">
|
@if (profileUser.description) {
|
||||||
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p>
|
<p class="mt-2 whitespace-pre-line text-sm leading-5 text-muted-foreground">{{ profileUser.description }}</p>
|
||||||
<p class="truncate text-sm text-muted-foreground">{{ user().username }}</p>
|
}
|
||||||
|
|
||||||
@if (editable()) {
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">Click avatar to upload and crop a profile picture.</p>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (avatarError()) {
|
@if (avatarError()) {
|
||||||
<div class="mt-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
<div class="mt-2.5 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
||||||
{{ avatarError() }}
|
{{ avatarError() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (editable()) {
|
@if (isEditable) {
|
||||||
<div class="relative mt-3">
|
<div class="relative mt-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-secondary/60"
|
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-secondary/60"
|
||||||
@@ -66,9 +110,9 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="h-2 w-2 rounded-full"
|
class="h-2 w-2 rounded-full"
|
||||||
[class]="currentStatusColor()"
|
[class]="statusColor"
|
||||||
></span>
|
></span>
|
||||||
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
|
<span class="flex-1 text-left text-foreground">{{ statusLabel }}</span>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideChevronDown"
|
name="lucideChevronDown"
|
||||||
class="h-3 w-3 text-muted-foreground"
|
class="h-3 w-3 text-muted-foreground"
|
||||||
@@ -97,9 +141,9 @@
|
|||||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span
|
<span
|
||||||
class="h-2 w-2 rounded-full"
|
class="h-2 w-2 rounded-full"
|
||||||
[class]="currentStatusColor()"
|
[class]="statusColor"
|
||||||
></span>
|
></span>
|
||||||
<span>{{ currentStatusLabel() }}</span>
|
<span>{{ statusLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -37,6 +38,9 @@ export class ProfileCardComponent {
|
|||||||
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
|
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
|
||||||
readonly avatarError = signal<string | null>(null);
|
readonly avatarError = signal<string | null>(null);
|
||||||
readonly avatarSaving = signal(false);
|
readonly avatarSaving = signal(false);
|
||||||
|
readonly editingField = signal<'displayName' | 'description' | null>(null);
|
||||||
|
readonly displayNameDraft = signal('');
|
||||||
|
readonly descriptionDraft = signal('');
|
||||||
|
|
||||||
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
|
||||||
{ value: null, label: 'Online', color: 'bg-green-500' },
|
{ value: null, label: 'Online', color: 'bg-green-500' },
|
||||||
@@ -49,6 +53,19 @@ export class ProfileCardComponent {
|
|||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
||||||
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
||||||
|
private readonly syncProfileDrafts = effect(() => {
|
||||||
|
const user = this.user();
|
||||||
|
const editingField = this.editingField();
|
||||||
|
|
||||||
|
if (editingField !== 'displayName') {
|
||||||
|
this.displayNameDraft.set(user.displayName || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingField !== 'description') {
|
||||||
|
this.descriptionDraft.set(user.description || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
|
||||||
currentStatusColor(): string {
|
currentStatusColor(): string {
|
||||||
switch (this.user().status) {
|
switch (this.user().status) {
|
||||||
@@ -81,6 +98,31 @@ export class ProfileCardComponent {
|
|||||||
this.showStatusMenu.set(false);
|
this.showStatusMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDisplayNameInput(event: Event): void {
|
||||||
|
this.displayNameDraft.set((event.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDescriptionInput(event: Event): void {
|
||||||
|
this.descriptionDraft.set((event.target as HTMLTextAreaElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
startEdit(field: 'displayName' | 'description'): void {
|
||||||
|
if (!this.editable() || this.editingField() === field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editingField.set(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishEdit(field: 'displayName' | 'description'): void {
|
||||||
|
if (this.editingField() !== field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.commitProfileDrafts();
|
||||||
|
this.editingField.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
pickAvatar(fileInput: HTMLInputElement): void {
|
pickAvatar(fileInput: HTMLInputElement): void {
|
||||||
if (!this.editable() || this.avatarSaving()) {
|
if (!this.editable() || this.avatarSaving()) {
|
||||||
return;
|
return;
|
||||||
@@ -147,4 +189,49 @@ export class ProfileCardComponent {
|
|||||||
this.avatarSaving.set(false);
|
this.avatarSaving.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private commitProfileDrafts(): void {
|
||||||
|
if (!this.editable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = this.normalizeDisplayName(this.displayNameDraft());
|
||||||
|
|
||||||
|
if (!displayName) {
|
||||||
|
this.displayNameDraft.set(this.user().displayName || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.user();
|
||||||
|
const description = this.normalizeDescription(this.descriptionDraft());
|
||||||
|
|
||||||
|
if (
|
||||||
|
displayName === this.normalizeDisplayName(user.displayName)
|
||||||
|
&& description === this.normalizeDescription(user.description)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
displayName,
|
||||||
|
description,
|
||||||
|
profileUpdatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile }));
|
||||||
|
this.user.update((user) => ({
|
||||||
|
...user,
|
||||||
|
...profile
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDisplayName(value: string | undefined): string {
|
||||||
|
return value?.trim().replace(/\s+/g, ' ') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDescription(value: string | undefined): string | undefined {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { DatabaseService } from '../../infrastructure/persistence';
|
|||||||
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
||||||
import { DebuggingService } from '../../core/services';
|
import { DebuggingService } from '../../core/services';
|
||||||
import { AttachmentFacade } from '../../domains/attachment';
|
import { AttachmentFacade } from '../../domains/attachment';
|
||||||
|
import { hasDedicatedChatEmbed } from '../../domains/chat/domain/rules/link-embed.rules';
|
||||||
import { LinkMetadataService } from '../../domains/chat/application/services/link-metadata.service';
|
import { LinkMetadataService } from '../../domains/chat/application/services/link-metadata.service';
|
||||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
import {
|
import {
|
||||||
@@ -388,7 +389,8 @@ export class MessagesEffects {
|
|||||||
if (message.isDeleted || message.linkMetadata?.length)
|
if (message.isDeleted || message.linkMetadata?.length)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
const urls = this.linkMetadata.extractUrls(message.content);
|
const urls = this.linkMetadata.extractUrls(message.content)
|
||||||
|
.filter((url) => !hasDedicatedChatEmbed(url));
|
||||||
|
|
||||||
if (urls.length === 0)
|
if (urls.length === 0)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user