9 Commits

Author SHA1 Message Date
Myx
53389ed3ad feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 5m54s
Queue Release Build / build-windows (push) Successful in 16m19s
Queue Release Build / build-linux (push) Successful in 30m13s
Queue Release Build / finalize (push) Successful in 47s
2026-04-27 05:46:33 +02:00
Myx
3858beb28e feat: Data management 2026-04-27 03:29:41 +02:00
Myx
1b91eacb5b feat: Theme studio v2 2026-04-27 03:02:13 +02:00
Myx
11c2588e45 feat: Add pm 2026-04-27 01:02:39 +02:00
Myx
bc2fa7de22 fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-26 22:54:13 +02:00
Myx
44588e8789 feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
2026-04-18 21:27:04 +02:00
Myx
167c45ba8d test: Add 8 people voice tests 2026-04-18 14:24:11 +02:00
Myx
bd21568726 feat: Add user metadata changing display name and description with sync
All checks were successful
Queue Release Build / prepare (push) Successful in 28s
Deploy Web Apps / deploy (push) Successful in 5m2s
Queue Release Build / build-windows (push) Successful in 16m44s
Queue Release Build / build-linux (push) Successful in 27m12s
Queue Release Build / finalize (push) Successful in 22s
2026-04-17 22:55:50 +02:00
Myx
3ba8a2c9eb fix: Fix corrupt database, Add soundcloud and spotify embeds 2026-04-17 19:44:26 +02:00
258 changed files with 17017 additions and 1895 deletions

5
.gitignore vendored
View File

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

165
README.md
View File

@@ -1,119 +1,88 @@
<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
- `electron/` desktop shell, IPC, and local database
- `server/` directory server, join request API, and websocket events
- `website/` Toju website served at toju.app
| Path | Purpose | Docs |
| --- | --- | --- |
| `toju-app/` | Angular 21 product client | [toju-app/README.md](toju-app/README.md) |
| `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
1. Run `npm install`
2. Run `cd server && npm install`
3. Copy `.env.example` to `.env`
1. Run `npm install` from the repository root.
2. Run `cd server && npm install` for the server package.
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
- `PORT=3001` changes the server port in local development and overrides the server app setting
## Main Commands
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:
- `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
## Repository Map
| Path | Description |
|------|-------------|
| `src/app/` | Main application root |
| `src/app/core/` | Core utilities, services, models |
| `src/app/domains/` | Domain-driven modules |
| `src/app/features/` | UI feature modules |
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
| `src/app/shared/` | Shared UI components |
| `src/app/shared-kernel/` | Shared domain contracts & models |
| `src/app/store/` | Global state management |
| `src/assets/` | Static assets |
| `src/environments/` | Environment configs |
| --- | --- |
| `toju-app/src/app/domains/` | Product-client bounded contexts and domain facades |
| `toju-app/src/app/infrastructure/` | Shared client-side technical runtime such as persistence and realtime |
| `toju-app/src/app/shared-kernel/` | Cross-domain contracts shared inside the product client |
| `electron/` | Electron bootstrap, preload surface, IPC handlers, CQRS, and desktop adapters |
| `server/src/` | Express app, websocket runtime, config, CQRS, and persistence layers |
| `e2e/` | Playwright tests, helpers, fixtures, and page objects |
| `website/src/` | Marketing-site pages, assets, and SSR entry points |
| `tools/` | Build, release, formatting, and packaging scripts |
---
## 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 |
|------|------|
| 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) |
## Supporting Docs
---
- [doc/monorepo.md](doc/monorepo.md)
- [doc/typescript.md](doc/typescript.md)
- [docs/architecture.md](docs/architecture.md)
### Infrastructure
## Screenshots
| Path | Link |
|------|------|
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
---
### Shared Kernel
| Path | Link |
|------|------|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
---
### Entry Points
| File | Link |
|------|------|
| Main | [main.ts](src/main.ts) |
| Index HTML | [index.html](src/index.html) |
| App Root | [app/app.ts](src/app/app.ts) |
<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">

36
e2e/README.md Normal file
View File

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

View File

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

View File

@@ -3,6 +3,15 @@ import { type BrowserContext, type Page } from '@playwright/test';
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
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 {
key: string;
removedKey: string;
@@ -17,21 +26,32 @@ interface SeededEndpointStorageState {
}
function buildSeededEndpointStorageState(
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
endpointsOrPort: readonly SeededEndpointInput[] | number = Number(process.env.TEST_SERVER_PORT) || 3099
): 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',
name: 'E2E Test Server',
url: `http://localhost:${port}`,
url: `http://localhost:${endpointsOrPort}`,
isActive: true,
isDefault: false,
status: 'unknown'
};
}
];
return {
key: SERVER_ENDPOINTS_STORAGE_KEY,
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
endpoints: [endpoint]
endpoints
};
}
@@ -59,6 +79,15 @@ export async function installTestServerEndpoint(
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.
* 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);
}
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
View File

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

View File

@@ -46,75 +46,6 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
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
// (canvas-based video + 880Hz oscillator audio) so the browser
// 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.
*/
/**
* 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> {
await page.waitForFunction(
() => (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
* connections. Uses a per-connection high water mark stored on `window` so

View File

@@ -19,13 +19,65 @@ export class ChatRoomPage {
/** Click a voice channel by name in the channels sidebar to join voice. */
async joinVoiceChannel(channelName: string) {
const channelButton = this.page.locator('app-rooms-side-panel')
.getByRole('button', { name: channelName, exact: true });
const channelButton = this.getVoiceChannelButton(channelName);
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 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. */
async joinTextChannel(channelName: string) {
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();
}
/** 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 disconnectButton() {
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. */
async getVoiceUserCountInChannel(channelName: string): Promise<number> {
const channelSection = this.page.locator('app-rooms-side-panel')
.getByRole('button', { name: channelName })
.locator('..');
const userAvatars = channelSection.locator('app-user-avatar');
// The voice channel button is inside a wrapper div; user avatars are siblings within that wrapper
const channelWrapper = this.getVoiceChannelButton(channelName).locator('xpath=ancestor::div[1]');
const userAvatars = channelWrapper.locator('app-user-avatar');
return userAvatars.count();
}
@@ -154,9 +210,11 @@ export class ChatRoomPage {
}
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> {
@@ -384,7 +442,3 @@ export class ChatRoomPage {
await this.page.waitForTimeout(500);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -1,6 +1,7 @@
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly form: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly serverSelect: Locator;
@@ -9,12 +10,13 @@ export class LoginPage {
readonly registerLink: Locator;
constructor(private page: Page) {
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first();
this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server');
this.submitButton = page.getByRole('button', { name: 'Login' });
this.submitButton = this.form.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive');
this.registerLink = page.getByRole('button', { name: 'Register' });
this.registerLink = this.form.getByRole('button', { name: 'Register' });
}
async goto() {

View File

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

View File

@@ -7,6 +7,8 @@ import {
export class ServerSearchPage {
readonly searchInput: Locator;
readonly createServerButton: Locator;
readonly railCreateServerButton: Locator;
readonly searchCreateServerButton: Locator;
readonly settingsButton: Locator;
// Create server dialog
@@ -20,8 +22,10 @@ export class ServerSearchPage {
readonly dialogCancelButton: Locator;
constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers...');
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
this.searchInput = page.getByPlaceholder('Search servers and users...');
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"]');
// Create dialog elements
@@ -39,8 +43,20 @@ export class ServerSearchPage {
await this.page.goto('/search');
}
async createServer(name: string, options?: { description?: string; topic?: string }) {
await this.createServerButton.click();
async createServer(name: string, options?: { description?: string; topic?: string; sourceId?: string }) {
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 this.serverNameInput.fill(name);
@@ -52,6 +68,10 @@ export class ServerSearchPage {
await this.serverTopicInput.fill(options.topic);
}
if (options?.sourceId) {
await this.signalEndpointSelect.selectOption(options.sourceId);
}
await this.dialogCreateButton.click();
}
@@ -60,6 +80,11 @@ export class ServerSearchPage {
}
async joinServerFromSearch(name: string) {
await this.page.locator('button', { hasText: name }).click();
await this.searchInput.fill(name);
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
}
}

View File

@@ -6,7 +6,7 @@ export default defineConfig({
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
reporter: [['html', { outputFolder: '../test-results/html-report', open: 'never' }], ['list']],
outputDir: '../test-results/artifacts',
use: {
baseURL: 'http://localhost:4200',
@@ -22,7 +22,11 @@ export default defineConfig({
...devices['Desktop Chrome'],
permissions: ['microphone', 'camera'],
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'
]
}
}
}

View File

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

View File

@@ -18,6 +18,85 @@ const DELETED_MESSAGE_CONTENT = '[Message deleted]';
test.describe('Chat messaging features', () => {
test.describe.configure({ timeout: 180_000 });
test('shows per-server channel lists on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `Alpha Server ${uniqueName('rail')}`;
const betaServerName = `Beta Server ${uniqueName('rail')}`;
const alphaChannelName = uniqueName('alpha-updates');
const betaChannelName = uniqueName('beta-plans');
const channelsPanel = scenario.room.channelsSidePanel;
await test.step('Create first saved server with a unique text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail switch alpha server');
await scenario.room.ensureTextChannelExists(alphaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second saved server with a different text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail switch beta server');
await scenario.room.ensureTextChannelExists(betaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toHaveCount(0);
});
await test.step('Opening second server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toHaveCount(0);
});
});
test('shows local room history on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `History Alpha ${uniqueName('rail')}`;
const betaServerName = `History Beta ${uniqueName('rail')}`;
const alphaMessage = `Alpha history message ${uniqueName('msg')}`;
const betaMessage = `Beta history message ${uniqueName('msg')}`;
await test.step('Create first server and send a local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail history alpha server');
await scenario.messages.sendMessage(alphaMessage);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second server and send a different local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail history beta server');
await scenario.messages.sendMessage(betaMessage);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening second server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
});
});
test('syncs messages in a newly created text channel', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
const channelName = uniqueName('updates');
@@ -143,6 +222,43 @@ interface ChatScenario {
bobMessages: ChatMessagesPage;
}
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
room: ChatRoomPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
await installChatFeatureMocks(client.page);
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
credentials.username,
credentials.displayName,
credentials.password
);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
room: new ChatRoomPage(client.page),
search: new ServerSearchPage(client.page)
};
}
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
const suffix = uniqueName('chat');
const serverName = `Chat Server ${suffix}`;
@@ -192,11 +308,8 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearchPage = new ServerSearchPage(bob.page);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await bobSearchPage.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await bobSearchPage.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const aliceRoom = new ChatRoomPage(alice.page);
@@ -217,6 +330,52 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
};
}
async function createServerAndOpenRoom(
searchPage: ServerSearchPage,
page: Page,
serverName: string,
description: string
): Promise<void> {
await searchPage.createServer(serverName, { description });
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(page, serverName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
async function installChatFeatureMocks(page: Page): Promise<void> {
await page.route('**/api/klipy/config', async (route) => {
await route.fulfill({

View File

@@ -0,0 +1,114 @@
import { type Page } from '@playwright/test';
import {
test,
expect,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
test.describe('Direct message flow', () => {
test.describe.configure({ timeout: 180_000 });
test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
const offlineMessage = `Offline DM ${uniqueName('msg')}`;
await test.step('Alice opens Bob from the room user list', async () => {
const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 });
});
await test.step('Offline send persists locally as queued', async () => {
await scenario.alice.page.evaluate(() => window.simulateOffline?.());
await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 });
await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED');
});
});
test('shows friend and message actions on the search people list', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
.first();
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' });
await expect(friendButton).toBeAttached({ timeout: 15_000 });
await expect(messageButton).toBeAttached({ timeout: 15_000 });
});
});
interface DmScenario {
alice: Client;
bob: Client;
bobUserId: string;
aliceSearch: ServerSearchPage;
}
async function createDmScenario(createClient: () => Promise<Client>): Promise<DmScenario> {
const suffix = uniqueName('dm');
const serverName = `DM Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(alice.page).waitForReady();
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(bob.page).waitForReady();
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
if (!bobUserId) {
throw new Error('Expected Bob room user card to expose a stable test id.');
}
return {
alice,
bob,
bobUserId,
aliceSearch
};
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,260 @@
import {
expect,
type Locator,
type Page
} from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
interface DesktopNotificationRecord {
title: string;
body: string;
}
interface NotificationScenario {
alice: Client;
bob: Client;
aliceRoom: ChatRoomPage;
bobRoom: ChatRoomPage;
bobMessages: ChatMessagesPage;
serverName: string;
channelName: string;
}
test.describe('Chat notifications', () => {
test.describe.configure({ timeout: 180_000 });
test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Background notification ${uniqueName('msg')}`;
await test.step('Bob sends a message to Alice\'s inactive channel', async () => {
await clearDesktopNotifications(scenario.alice.page);
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice receives a desktop notification with the channel preview', async () => {
const notification = await waitForDesktopNotification(scenario.alice.page);
expect(notification).toEqual({
title: `${scenario.serverName} · #${scenario.channelName}`,
body: `Bob: ${message}`
});
});
await test.step('Alice sees unread badges for the room and the inactive channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
});
test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Muted notification ${uniqueName('msg')}`;
await test.step('Alice mutes the inactive text channel', async () => {
await muteTextChannel(scenario.alice.page, scenario.channelName);
await clearDesktopNotifications(scenario.alice.page);
});
await test.step('Bob sends a message into the muted channel', async () => {
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice still sees unread badges for the room and channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
await test.step('Alice does not get a muted desktop popup', async () => {
const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500);
expect(notificationAppeared).toBe(false);
});
});
});
async function createNotificationScenario(createClient: () => Promise<Client>): Promise<NotificationScenario> {
const suffix = uniqueName('notify');
const serverName = `Notifications Server ${suffix}`;
const channelName = uniqueName('updates');
const aliceCredentials = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobCredentials = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const alice = await createClient();
const bob = await createClient();
await installDesktopNotificationSpy(alice.page);
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, {
description: 'E2E notification coverage server'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page);
await aliceMessages.waitForReady();
await bobMessages.waitForReady();
await aliceRoom.ensureTextChannelExists(channelName);
await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 });
return {
alice,
bob,
aliceRoom,
bobRoom,
bobMessages,
serverName,
channelName
};
}
async function registerUser(page: Page, username: string, displayName: string, password: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function installDesktopNotificationSpy(page: Page): Promise<void> {
await page.addInitScript(() => {
const notifications: DesktopNotificationRecord[] = [];
class MockNotification {
static permission = 'granted';
onclick: (() => void) | null = null;
constructor(title: string, options?: NotificationOptions) {
notifications.push({
title,
body: options?.body ?? ''
});
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {
return;
}
}
Object.defineProperty(window, '__desktopNotifications', {
value: notifications,
configurable: true
});
Object.defineProperty(window, 'Notification', {
value: MockNotification,
configurable: true,
writable: true
});
});
}
async function clearDesktopNotifications(page: Page): Promise<void> {
await page.evaluate(() => {
(window as WindowWithDesktopNotifications).__desktopNotifications.length = 0;
});
}
async function waitForDesktopNotification(page: Page): Promise<DesktopNotificationRecord> {
await expect.poll(
async () => (await readDesktopNotifications(page)).length,
{
timeout: 20_000,
message: 'Expected a desktop notification to be emitted'
}
).toBeGreaterThan(0);
const notifications = await readDesktopNotifications(page);
return notifications[notifications.length - 1];
}
async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise<boolean> {
try {
await page.waitForFunction(
() => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0,
undefined,
{ timeout }
);
return true;
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return false;
}
throw error;
}
}
async function readDesktopNotifications(page: Page): Promise<DesktopNotificationRecord[]> {
return page.evaluate(() => {
return [...(window as WindowWithDesktopNotifications).__desktopNotifications];
});
}
async function muteTextChannel(page: Page, channelName: string): Promise<void> {
const channelButton = getTextChannelButton(page, channelName);
const contextMenu = page.locator('app-context-menu');
await expect(channelButton).toBeVisible({ timeout: 20_000 });
await channelButton.click({ button: 'right' });
await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 });
await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click();
await expect(contextMenu).toHaveCount(0);
}
function getSavedRoomButton(page: Page, roomName: string): Locator {
return page.locator(`button[title="${roomName}"]`).first();
}
function getTextChannelButton(page: Page, channelName: string): Locator {
return page.locator('app-rooms-side-panel').first()
.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`)
.first();
}
function getUnreadBadge(container: Locator): Locator {
return container.locator('span.rounded-full').first();
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}
interface WindowWithDesktopNotifications extends Window {
__desktopNotifications: DesktopNotificationRecord[];
}

View File

@@ -1,7 +1,4 @@
import {
mkdtemp,
rm
} from 'node:fs/promises';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
@@ -9,11 +6,9 @@ import {
type BrowserContext,
type Page
} from '@playwright/test';
import {
test,
expect
} from '../../fixtures/multi-client';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
@@ -40,17 +35,39 @@ interface PersistentClient {
userDataDir: string;
}
interface ProfileMetadata {
description?: string;
displayName: string;
}
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]);
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
0x21, 0xFF, 0x0B,
0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30,
0x03, 0x01, 0x00, 0x00, 0x00
const GIF_FRAME_MARKER = Buffer.from([
0x21,
0xF9,
0x04
]);
const CLIENT_LAUNCH_ARGS = [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream'
];
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
0x21,
0xFF,
0x0B,
0x4E,
0x45,
0x54,
0x53,
0x43,
0x41,
0x50,
0x45,
0x32,
0x2E,
0x30,
0x03,
0x01,
0x00,
0x00,
0x00
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const VOICE_CHANNEL = 'General';
test.describe('Profile avatar sync', () => {
@@ -100,6 +117,8 @@ test.describe('Profile avatar sync', () => {
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);
});
@@ -126,6 +145,8 @@ test.describe('Profile avatar sync', () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await waitForConnectedPeerCount(alice.page, 2);
await waitForConnectedPeerCount(carol.page, 1);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
});
@@ -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> {
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
const session = await launchPersistentSession(userDataDir, testServerPort);
@@ -220,6 +369,8 @@ async function launchPersistentSession(
const page = context.pages()[0] ?? await context.newPage();
await installWebRTCTracking(page);
return { context, page };
}
@@ -233,11 +384,8 @@ async function registerUser(client: PersistentClient): Promise<void> {
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
const serverCard = page.locator('button', { hasText: serverName }).first();
await searchPage.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await searchPage.joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
@@ -288,6 +436,43 @@ async function uploadAvatarFromRoomSidebar(
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
}
async function updateProfileFromRoomSidebar(
page: Page,
currentProfile: ProfileMetadata,
nextProfile: ProfileMetadata
): Promise<void> {
const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName);
const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true });
await expect(displayNameButton).toBeVisible({ timeout: 10_000 });
await displayNameButton.click();
const displayNameInput = profileCard.locator('input[type="text"]').first();
await expect(displayNameInput).toBeVisible({ timeout: 10_000 });
await displayNameInput.fill(nextProfile.displayName);
await displayNameInput.blur();
await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 });
const currentDescriptionText = currentProfile.description || 'Add a description';
await profileCard.getByText(currentDescriptionText, { exact: true }).click();
const descriptionInput = profileCard.locator('textarea').first();
await expect(descriptionInput).toBeVisible({ timeout: 10_000 });
await descriptionInput.fill(nextProfile.description || '');
await descriptionInput.blur();
await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 });
await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });
if (nextProfile.description) {
await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 });
}
}
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
@@ -332,18 +517,73 @@ async function waitForRoomReady(page: Page): Promise<void> {
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
await page.waitForFunction((expectedCount) => {
const connections = (window as {
__rtcConnections?: RTCPeerConnection[];
}).__rtcConnections ?? [];
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
}, count, { timeout });
}
async function openProfileCardFromUserRow(page: Page, displayName: string) {
await closeProfileCard(page);
const row = getUserRow(page, displayName);
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
const profileCard = page.locator('app-profile-card');
await expect(profileCard).toBeVisible({ timeout: 10_000 });
return profileCard;
}
async function closeProfileCard(page: Page): Promise<void> {
const profileCard = page.locator('app-profile-card');
if (await profileCard.count() === 0) {
return;
}
try {
await expect(profileCard).toBeVisible({ timeout: 1_000 });
} catch {
return;
}
await page.mouse.click(8, 8);
await expect(profileCard).toHaveCount(0, { timeout: 10_000 });
}
function getUserRow(page: Page, displayName: string) {
const usersSidePanel = page.locator('app-rooms-side-panel').last();
return usersSidePanel.locator('[role="button"]').filter({
has: page.getByText(displayName, { exact: true })
}).first();
})
.first();
}
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
}
async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise<void> {
const profileCard = await openProfileCardFromUserRow(page, profile.displayName);
await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 });
if (profile.description) {
await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 });
}
await closeProfileCard(page);
}
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
const row = getUserRow(page, displayName);
@@ -400,6 +640,14 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected
}).toBe(expectedDataUrl);
}
async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
const messageItem = messagesPage.getMessageItemByText(messageText);
await expect(messageItem).toBeVisible({ timeout: 20_000 });
await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 });
}
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
const voiceControls = page.locator('app-voice-controls');
@@ -431,7 +679,11 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([
Buffer.from([0x21, 0xFE, commentData.length]),
Buffer.from([
0x21,
0xFE,
commentData.length
]),
commentData,
Buffer.from([0x00])
]);
@@ -454,5 +706,6 @@ function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -8,7 +8,8 @@ import {
waitForVideoFlow,
waitForOutboundVideoFlow,
waitForInboundVideoFlow,
dumpRtcDiagnostics
dumpRtcDiagnostics,
installAutoResumeAudioContext
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.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 });
}
/** Both users register Alice creates server Bob joins. */
/** Both users register -> Alice creates server -> Bob joins. */
async function setupServerWithBothUsers(
alice: { page: import('@playwright/test').Page },
bob: { page: import('@playwright/test').Page }
@@ -55,12 +56,7 @@ async function setupServerWithBothUsers(
// Bob joins server
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.searchInput.fill(SERVER_NAME);
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
await bobSearch.joinServerFromSearch(SERVER_NAME);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
@@ -80,11 +76,11 @@ async function joinVoiceTogether(
await expect(existingChannel).toBeVisible({ timeout: 10_000 });
}
const bobRoom = new ChatRoomPage(bob.page);
const doJoin = async () => {
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
const bobRoom = new ChatRoomPage(bob.page);
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
@@ -93,6 +89,32 @@ async function joinVoiceTogether(
await waitForPeerConnected(bob.page, 30_000);
await waitForAudioStatsPresent(alice.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
// share request flow can fire (requires connectRemoteShares = true).
@@ -142,6 +164,20 @@ test.describe('Screen sharing', () => {
await installWebRTCTracking(alice.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()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
@@ -251,6 +287,18 @@ test.describe('Screen sharing', () => {
await installWebRTCTracking(alice.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()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
@@ -323,6 +371,18 @@ test.describe('Screen sharing', () => {
await installWebRTCTracking(alice.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()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));

View File

@@ -0,0 +1,219 @@
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 and users...')).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 and users...')).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 and users...')).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.joinServerFromSearch(serverName);
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.joinServerFromSearch(serverName);
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
const charlieRoom = new ChatRoomPage(charlie.page);
// ── Everyone joins voice ──
await test.step('All three join voice', async () => {
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
await charlieRoom.joinVoiceChannel('General');
});
await test.step('All users see each other in voice', async () => {
// Each user should see the other two in the voice channel list.
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
aliceRoom.channelsSidePanel.getByText('Charlie')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Charlie')
).toBeVisible({ timeout: 20_000 });
await expect(
charlieRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await expect(
charlieRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
});
// ── Wait for full mesh to establish ──
await test.step('All peer connections establish', async () => {
// Each client should have 2 connected peers (full mesh of 3).
await waitForConnectedPeerCount(alice.page, 2, 30_000);
await waitForConnectedPeerCount(bob.page, 2, 30_000);
await waitForConnectedPeerCount(charlie.page, 2, 30_000);
});
// ── Break Charlie's connections ──
await test.step('Kill Charlie peer connections and block reconnection', async () => {
await killAndBlockPeerConnections(charlie.page);
// Give the health service time to detect the desync.
// Peer latency pings stop -> connectedPeers updates -> desyncPeerIds recalculates.
await alice.page.waitForTimeout(15_000);
});
// ── Assert connectivity warnings ──
//
// The warning icon (lucideAlertTriangle) is a direct sibling of the
// user-name span inside the same voice-row div. Using the CSS
// general-sibling combinator (~) avoids accidentally matching a
// parent container that holds multiple rows.
await test.step('Alice sees warning icon next to Charlie', async () => {
const charlieWarning = aliceRoom.channelsSidePanel
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
});
await test.step('Bob sees warning icon next to Charlie', async () => {
const charlieWarning = bobRoom.channelsSidePanel
.locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(charlieWarning).toBeVisible({ timeout: 30_000 });
});
await test.step('Alice does NOT see warning icon next to Bob', async () => {
const bobWarning = aliceRoom.channelsSidePanel
.locator('span.truncate:has-text("Bob") ~ ng-icon[name="lucideAlertTriangle"]');
await expect(bobWarning).not.toBeVisible();
});
await test.step('Charlie sees local desync banner', async () => {
const desyncBanner = charlie.page.locator('text=You may have connectivity issues');
await expect(desyncBanner).toBeVisible({ timeout: 30_000 });
});
});
});

View File

@@ -0,0 +1,127 @@
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 and users...')).toBeVisible({ timeout: 30_000 });
await page.getByTitle('Settings').click();
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
}
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('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
});
});
test('validates TURN entries require credentials', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = `iceval_${Date.now()}`;
await test.step('Register and open Network settings', async () => {
await registerAndOpenNetworkSettings(page, suffix);
});
await test.step('Adding TURN without credentials shows error', async () => {
await page.getByTestId('ice-type-select').selectOption('turn');
await page.getByTestId('ice-url-input').fill('turn:noncred.example.com:443');
await page.getByTestId('ice-add-button').click();
await expect(page.getByText('Username is required for TURN servers')).toBeVisible({ timeout: 5_000 });
});
});
});

View File

@@ -0,0 +1,212 @@
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 and users...')).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 and users...')).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.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
await test.step('Both join voice', async () => {
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
});
await test.step('Both users see each other in voice', async () => {
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
});
await test.step('Peer connections establish and audio flows bidirectionally', async () => {
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
// Wait for audio RTP stats to appear (tracks negotiated)
await waitForAudioStatsPresent(alice.page, 30_000);
await waitForAudioStatsPresent(bob.page, 30_000);
// Allow mesh to settle - voice routing and renegotiation can
// cause a second offer/answer cycle after the initial connection.
await alice.page.waitForTimeout(5_000);
// Chromium's --use-fake-device-for-media-stream can produce a
// silent capture track on the very first getUserMedia call. If
// bidirectional audio does not flow within a short window, leave
// and rejoin voice to re-acquire the mic (the second getUserMedia
// on a warm device always works).
let audioFlowing = false;
try {
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 15_000), waitForAllPeerAudioFlow(bob.page, 1, 15_000)]);
audioFlowing = true;
} catch {
// Silent sender detected - rejoin voice to work around Chromium bug
}
if (!audioFlowing) {
// Leave voice
await aliceRoom.disconnectButton.click();
await bobRoom.disconnectButton.click();
await alice.page.waitForTimeout(2_000);
// Rejoin
await aliceRoom.joinVoiceChannel('General');
await bobRoom.joinVoiceChannel('General');
await expect(
aliceRoom.channelsSidePanel.getByText('Bob')
).toBeVisible({ timeout: 20_000 });
await expect(
bobRoom.channelsSidePanel.getByText('Alice')
).toBeVisible({ timeout: 20_000 });
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
await waitForConnectedPeerCount(alice.page, 1, 30_000);
await waitForConnectedPeerCount(bob.page, 1, 30_000);
await waitForAudioStatsPresent(alice.page, 30_000);
await waitForAudioStatsPresent(bob.page, 30_000);
await alice.page.waitForTimeout(3_000);
}
// Final assertion - must succeed after the (optional) rejoin.
try {
await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 60_000), waitForAllPeerAudioFlow(bob.page, 1, 60_000)]);
} catch (error) {
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
throw error;
}
});
await test.step('Bob still has TURN entry in localStorage', async () => {
const stored: StoredIceServerEntry[] = await bob.page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '[]') as StoredIceServerEntry[],
ICE_STORAGE_KEY
);
const hasTurn = stored.some(
(entry) => entry.type === 'turn' && entry.urls === 'turn:localhost:3478'
);
expect(hasTurn).toBe(true);
});
});
});

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { test, expect } from '../../fixtures/multi-client';
import {
installWebRTCTracking,
installAutoResumeAudioContext,
waitForPeerConnected,
isPeerStillConnected,
getAudioStatsDelta,
@@ -13,7 +14,7 @@ import { ServerSearchPage } from '../../pages/server-search.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.
*
* 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 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.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
await installWebRTCTracking(alice.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
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
@@ -81,14 +96,7 @@ test.describe('Full user journey: register → server → voice chat', () => {
await test.step('Bob finds and joins the server', async () => {
const searchPage = new ServerSearchPage(bob.page);
// Search for the server
await searchPage.searchInput.fill(SERVER_NAME);
// Wait for search results and click the server
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
await searchPage.joinServerFromSearch(SERVER_NAME);
// Bob should be in the room now
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
@@ -146,8 +154,38 @@ test.describe('Full user journey: register → server → voice chat', () => {
// ── Step 7: Verify audio is flowing in both directions ───────────
await test.step('Audio packets are flowing between Alice and Bob', async () => {
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
// 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.
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
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {

32
electron/README.md Normal file
View File

@@ -0,0 +1,32 @@
# 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 |
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
| `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings |
| `db/`, `entities/`, `migrations/` | Desktop persistence and schema evolution |
| `audio/` | Desktop audio integrations |
| `update/` | Desktop updater flow |
| `window/` | Window creation and window-level behavior |
## Notes
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
- Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -5,6 +5,7 @@ import { createWindow, getMainWindow } from '../window/create-window';
const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
let pendingDeepLink: string | null = null;
@@ -95,6 +96,12 @@ export function initializeDeepLinkHandling(): boolean {
}
app.on('second-instance', (_event, argv) => {
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
app.relaunch();
app.exit(0);
return;
}
focusMainWindow();
const deepLink = extractDeepLink(argv);

View File

@@ -10,6 +10,8 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
oderId: user.oderId ?? null,
username: user.username ?? null,
displayName: user.displayName ?? null,
description: user.description ?? null,
profileUpdatedAt: user.profileUpdatedAt ?? null,
avatarUrl: user.avatarUrl ?? null,
avatarHash: user.avatarHash ?? null,
avatarMime: user.avatarMime ?? null,

View File

@@ -46,6 +46,8 @@ export function rowToUser(row: UserEntity) {
oderId: row.oderId ?? '',
username: row.username ?? '',
displayName: row.displayName ?? '',
description: row.description ?? undefined,
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,

View File

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

View File

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

View File

@@ -66,6 +66,8 @@ export interface RoomMemberRecord {
oderId?: string;
username: string;
displayName: string;
description?: string;
profileUpdatedAt?: number;
avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
@@ -338,16 +340,18 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
const username = trimmedString(rawMember, 'username');
const displayName = trimmedString(rawMember, 'displayName');
const description = trimmedString(rawMember, 'description');
const profileUpdatedAt = isFiniteNumber(rawMember['profileUpdatedAt']) ? rawMember['profileUpdatedAt'] : undefined;
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
const avatarHash = trimmedString(rawMember, 'avatarHash');
const avatarMime = trimmedString(rawMember, 'avatarMime');
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
return {
const member: RoomMemberRecord = {
id: normalizedId || normalizedKey,
oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
profileUpdatedAt,
avatarUrl: avatarUrl || undefined,
avatarHash: avatarHash || undefined,
avatarMime: avatarMime || undefined,
@@ -357,6 +361,12 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
joinedAt,
lastSeenAt
};
if (Object.prototype.hasOwnProperty.call(rawMember, 'description')) {
member.description = description || undefined;
}
return member;
}
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 existingProfileUpdatedAt = existingMember.profileUpdatedAt ?? 0;
const incomingProfileUpdatedAt = incomingMember.profileUpdatedAt ?? 0;
const preferIncomingProfile = incomingProfileUpdatedAt === existingProfileUpdatedAt
? preferIncoming
: incomingProfileUpdatedAt > existingProfileUpdatedAt;
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
@@ -377,9 +392,13 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
username: preferIncoming
? (incomingMember.username || existingMember.username)
: (existingMember.username || incomingMember.username),
displayName: preferIncoming
displayName: preferIncomingProfile
? (incomingMember.displayName || existingMember.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
? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl),
@@ -780,6 +799,8 @@ export async function replaceRoomRelations(
oderId: member.oderId ?? null,
username: member.username,
displayName: member.displayName,
description: member.description ?? null,
profileUpdatedAt: member.profileUpdatedAt ?? null,
avatarUrl: member.avatarUrl ?? null,
avatarHash: member.avatarHash ?? null,
avatarMime: member.avatarMime ?? null,
@@ -930,6 +951,8 @@ export async function loadRoomRelationsMap(
oderId: row.oderId ?? undefined,
username: row.username,
displayName: row.displayName,
description: row.description ?? undefined,
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,

View File

@@ -27,6 +27,7 @@ export const QueryType = {
GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user',
GetCurrentUser: 'get-current-user',
GetCurrentUserId: 'get-current-user-id',
GetUsersByRoom: 'get-users-by-room',
GetRoom: 'get-room',
GetAllRooms: 'get-all-rooms',
@@ -105,6 +106,8 @@ export interface UserPayload {
oderId?: string;
username?: string;
displayName?: string;
description?: string;
profileUpdatedAt?: number;
avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
@@ -212,6 +215,7 @@ export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; pa
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
export interface GetCurrentUserIdQuery { type: typeof QueryType.GetCurrentUserId; payload: Record<string, never> }
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
@@ -227,6 +231,7 @@ export type Query =
| GetReactionsForMessageQuery
| GetUserQuery
| GetCurrentUserQuery
| GetCurrentUserIdQuery
| GetUsersByRoomQuery
| GetRoomQuery
| GetAllRoomsQuery

229
electron/data-archive.ts Normal file
View File

@@ -0,0 +1,229 @@
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface ZipArchiveEntry {
data: Buffer;
path: string;
}
interface CentralDirectoryEntry {
compressedSize: number;
crc: number;
data: Buffer;
localHeaderOffset: number;
name: Buffer;
uncompressedSize: number;
}
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const ZIP_UTF8_FLAG = 0x0800;
const ZIP_STORE_METHOD = 0;
const ZIP_VERSION = 20;
const MAX_UINT32 = 0xffffffff;
const crcTable = buildCrcTable();
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
const localParts: Buffer[] = [];
const centralEntries: CentralDirectoryEntry[] = [];
let offset = 0;
for (const entry of entries) {
const normalizedPath = normalizeZipPath(entry.path);
const name = Buffer.from(normalizedPath, 'utf8');
const data = entry.data;
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const crc = crc32(data);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
localHeader.writeUInt16LE(ZIP_VERSION, 4);
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(data.length, 18);
localHeader.writeUInt32LE(data.length, 22);
localHeader.writeUInt16LE(name.length, 26);
localHeader.writeUInt16LE(0, 28);
localParts.push(localHeader, name, data);
centralEntries.push({
compressedSize: data.length,
crc,
data,
localHeaderOffset: offset,
name,
uncompressedSize: data.length
});
offset += localHeader.length + name.length + data.length;
}
const centralDirectoryOffset = offset;
const centralParts = centralEntries.map((entry) => {
const header = Buffer.alloc(46);
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
header.writeUInt16LE(ZIP_VERSION, 4);
header.writeUInt16LE(ZIP_VERSION, 6);
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(0, 14);
header.writeUInt32LE(entry.crc, 16);
header.writeUInt32LE(entry.compressedSize, 20);
header.writeUInt32LE(entry.uncompressedSize, 24);
header.writeUInt16LE(entry.name.length, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
header.writeUInt32LE(0, 38);
header.writeUInt32LE(entry.localHeaderOffset, 42);
offset += header.length + entry.name.length;
return Buffer.concat([header, entry.name]);
});
const centralDirectorySize = offset - centralDirectoryOffset;
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const end = Buffer.alloc(22);
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(centralEntries.length, 8);
end.writeUInt16LE(centralEntries.length, 10);
end.writeUInt32LE(centralDirectorySize, 12);
end.writeUInt32LE(centralDirectoryOffset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, ...centralParts, end]);
}
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
const endOffset = findEndOfCentralDirectory(data);
if (endOffset < 0) {
throw new Error('The selected file is not a supported data archive.');
}
const entryCount = data.readUInt16LE(endOffset + 10);
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
const entries: ZipArchiveEntry[] = [];
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('The data archive directory is invalid.');
}
const method = data.readUInt16LE(offset + 10);
const compressedSize = data.readUInt32LE(offset + 20);
const uncompressedSize = data.readUInt32LE(offset + 24);
const nameLength = data.readUInt16LE(offset + 28);
const extraLength = data.readUInt16LE(offset + 30);
const commentLength = data.readUInt16LE(offset + 32);
const localHeaderOffset = data.readUInt32LE(offset + 42);
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
throw new Error('Compressed data archives are not supported by this build.');
}
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
throw new Error('The data archive contains an invalid file entry.');
}
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
entries.push({
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
path: entryPath
});
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
const destinationRoot = path.resolve(destinationPath);
for (const entry of entries) {
const targetPath = path.resolve(destinationRoot, entry.path);
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
throw new Error('The data archive contains an unsafe path.');
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.writeFile(targetPath, entry.data);
}
}
function findEndOfCentralDirectory(data: Buffer): number {
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
return offset;
}
}
return -1;
}
function normalizeZipPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
throw new Error('The data archive contains an unsafe path.');
}
return normalized;
}
function buildCrcTable(): number[] {
const table: number[] = [];
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0
? 0xedb88320 ^ (value >>> 1)
: value >>> 1;
}
table[index] = value >>> 0;
}
return table;
}
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}

257
electron/data-management.ts Normal file
View File

@@ -0,0 +1,257 @@
import {
app,
dialog,
shell
} from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { destroyDatabase, initializeDatabase } from './db/database';
import {
createZipArchive,
extractZipEntries,
readZipArchive,
type ZipArchiveEntry
} from './data-archive';
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json';
const ARCHIVE_DATA_PREFIX = 'data/';
const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups';
export async function openCurrentDataFolder(): Promise<boolean> {
const error = await shell.openPath(app.getPath('userData'));
return error.length === 0;
}
export async function exportUserData(): Promise<ExportUserDataResult> {
const dataPath = app.getPath('userData');
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: path.join(app.getPath('documents'), defaultFileName),
filters: [
{ extensions: ['dat'], name: 'MetoYou data archive' }
],
title: 'Export MetoYou data'
});
if (canceled || !filePath) {
return { cancelled: true, exported: false };
}
const entries: ZipArchiveEntry[] = [
{
data: Buffer.from(JSON.stringify({
appVersion: app.getVersion(),
exportedAt: new Date().toISOString(),
format: 'metoyou-user-data',
version: 1
}, null, 2)),
path: ARCHIVE_MANIFEST_PATH
}
];
for (const file of await collectDataFiles(dataPath)) {
const relativePath = toArchivePath(path.relative(dataPath, file));
entries.push({
data: await fsp.readFile(file),
path: `${ARCHIVE_DATA_PREFIX}${relativePath}`
});
}
await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries));
return {
cancelled: false,
exported: true,
filePath: ensureDatExtension(filePath)
};
}
export async function importUserData(): Promise<ImportUserDataResult> {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
],
properties: ['openFile'],
title: 'Import MetoYou data'
});
if (canceled || filePaths.length === 0) {
return {
cancelled: true,
imported: false,
restartRequired: false
};
}
const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0]));
validateArchiveManifest(archiveEntries);
const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`);
const importDataPath = path.join(importRoot, 'data');
try {
await extractZipEntries(
archiveEntries
.filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX))
.map((entry) => ({
data: entry.data,
path: entry.path.slice(ARCHIVE_DATA_PREFIX.length)
})),
importDataPath
);
await destroyDatabase();
const backupPath = await moveCurrentDataAside();
await copyDirectory(importDataPath, app.getPath('userData'));
await initializeDatabase();
return {
backupPath,
cancelled: false,
imported: true,
restartRequired: true
};
} catch (error) {
await initializeDatabase().catch(() => {});
throw error;
} finally {
await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {});
}
}
export async function eraseUserData(): Promise<EraseUserDataResult> {
const dataPath = app.getPath('userData');
await destroyDatabase();
for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) {
await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true });
}
await fsp.mkdir(dataPath, { recursive: true });
await initializeDatabase();
return {
erased: true,
restartRequired: true
};
}
async function collectDataFiles(directoryPath: string): Promise<string[]> {
const files: string[] = [];
const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
files.push(...await collectDataFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}
async function moveCurrentDataAside(): Promise<string | undefined> {
const dataPath = app.getPath('userData');
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
await fsp.mkdir(backupPath, { recursive: true });
let movedAny = false;
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const sourcePath = path.join(dataPath, entry.name);
const targetPath = path.join(backupPath, entry.name);
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.rename(sourcePath, targetPath).catch(async () => {
await copyPath(sourcePath, targetPath);
await fsp.rm(sourcePath, { force: true, recursive: true });
});
movedAny = true;
}
return movedAny ? backupPath : undefined;
}
async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
await fsp.mkdir(targetPath, { recursive: true });
for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) {
await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name));
}
}
async function copyPath(sourcePath: string, targetPath: string): Promise<void> {
const stats = await fsp.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectory(sourcePath, targetPath);
return;
}
if (stats.isFile()) {
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.copyFile(sourcePath, targetPath);
}
}
function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
if (!manifest) {
throw new Error('The selected file is missing a MetoYou data manifest.');
}
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) {
throw new Error('The selected file uses an unsupported data archive format.');
}
}
function ensureDatExtension(filePath: string): string {
return path.extname(filePath).toLowerCase() === '.dat'
? filePath
: `${filePath}.dat`;
}
function toArchivePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}

View File

@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto';
import { app } from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
@@ -20,23 +21,93 @@ import {
import { settings } from '../settings';
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 {
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> {
const userDataPath = app.getPath('userData');
const dbDir = path.join(userDataPath, 'metoyou');
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;
if (fs.existsSync(databaseFilePath)) {
database = fs.readFileSync(databaseFilePath);
}
const database = safeguardDbFile();
applicationDataSource = new DataSource({
type: 'sqljs',
@@ -59,12 +130,12 @@ export async function initializeDatabase(): Promise<void> {
synchronize: false,
logging: false,
autoSave: true,
location: databaseFilePath
autoSaveCallback: atomicSave
});
try {
await applicationDataSource.initialize();
console.log('[DB] Connection initialised at:', databaseFilePath);
console.log('[DB] Connection initialised at:', dbFilePath);
try {
await applicationDataSource.runMigrations();

View File

@@ -24,6 +24,12 @@ export class RoomMemberEntity {
@Column('text')
displayName!: string;
@Column('text', { nullable: true })
description!: string | null;
@Column('integer', { nullable: true })
profileUpdatedAt!: number | null;
@Column('text', { nullable: true })
avatarUrl!: string | null;

View File

@@ -18,6 +18,12 @@ export class UserEntity {
@Column('text', { nullable: true })
displayName!: string | null;
@Column('text', { nullable: true })
description!: string | null;
@Column('integer', { nullable: true })
profileUpdatedAt!: number | null;
@Column('text', { nullable: true })
avatarUrl!: string | null;

View File

@@ -49,6 +49,13 @@ import {
readSavedTheme,
writeSavedTheme
} from '../theme-library';
import {
eraseUserData,
exportUserData,
importUserData,
openCurrentDataFolder
} from '../data-management';
import { listRunningProcessNames } from '../process-list';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -314,6 +321,8 @@ export function setupSystemHandlers(): void {
}
});
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
return await prepareLinuxScreenShareAudioRouting();
});
@@ -335,6 +344,10 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
ipcMain.handle('export-user-data', async () => await exportUserData());
ipcMain.handle('import-user-data', async () => await importUserData());
ipcMain.handle('erase-user-data', async () => await eraseUserData());
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));

View File

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

View File

@@ -109,6 +109,24 @@ export interface SavedThemeFileDescriptor {
path: string;
}
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
@@ -149,6 +167,7 @@ export interface ElectronAPI {
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
@@ -157,6 +176,10 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>;
@@ -230,6 +253,7 @@ const electronAPI: ElectronAPI = {
openExternal: (url) => ipcRenderer.invoke('open-external', url),
getSources: () => ipcRenderer.invoke('get-sources'),
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
@@ -265,6 +289,10 @@ const electronAPI: ElectronAPI = {
};
},
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'),
importUserData: () => ipcRenderer.invoke('import-user-data'),
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),

85
electron/process-list.ts Normal file
View File

@@ -0,0 +1,85 @@
import { execFile } from 'child_process';
import * as path from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const MAX_PROCESS_NAMES = 512;
export async function listRunningProcessNames(): Promise<string[]> {
if (process.platform === 'win32') {
return normalizeProcessNames(await listWindowsProcessNames());
}
if (process.platform === 'linux') {
return normalizeProcessNames(await listLinuxProcessNames());
}
return [];
}
async function listLinuxProcessNames(): Promise<string[]> {
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
maxBuffer: 1024 * 1024,
timeout: 5_000
});
return stdout.split('\n');
}
async function listWindowsProcessNames(): Promise<string[]> {
const { stdout } = await execFileAsync('tasklist', [
'/FO',
'CSV',
'/NH'
], {
maxBuffer: 1024 * 1024,
timeout: 5_000,
windowsHide: true
});
return stdout
.split(/\r?\n/)
.map((line) => parseCsvFirstColumn(line));
}
function parseCsvFirstColumn(line: string): string {
const trimmed = line.trim();
if (!trimmed) {
return '';
}
if (!trimmed.startsWith('"')) {
return trimmed.split(',')[0] ?? '';
}
const endQuoteIndex = trimmed.indexOf('"', 1);
return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : '';
}
function normalizeProcessNames(names: string[]): string[] {
const normalized = new Set<string>();
for (const rawName of names) {
const name = normalizeProcessName(rawName);
if (name) {
normalized.add(name);
}
}
return Array.from(normalized)
.sort()
.slice(0, MAX_PROCESS_NAMES);
}
function normalizeProcessName(rawName: string): string {
const baseName = path.basename(rawName.trim()).trim();
if (!baseName || baseName.length > 96) {
return '';
}
return baseName;
}

View File

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

View File

@@ -15,8 +15,37 @@ let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
let youtubeRequestHeadersConfigured = false;
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 {
const basePath = app.isPackaged
@@ -163,6 +192,7 @@ export async function createWindow(): Promise<void> {
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
ensureYoutubeEmbedRequestHeaders();
mainWindow = new BrowserWindow({
width: 1400,

View File

@@ -5,41 +5,7 @@ const angular = require('angular-eslint');
const stylisticTs = require('@stylistic/eslint-plugin-ts');
const stylisticJs = require('@stylistic/eslint-plugin-js');
const newlines = require('eslint-plugin-import-newlines');
// 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);
}
}
}
};
}
}
}
};
const metoyouEslintRules = require('./tools/eslint-rules');
module.exports = tseslint.config(
{
@@ -51,7 +17,7 @@ module.exports = tseslint.config(
'@stylistic/ts': stylisticTs,
'@stylistic/js': stylisticJs,
'import-newlines': newlines,
'no-dashes': noDashPlugin
'metoyou': metoyouEslintRules
},
extends: [
eslint.configs.recommended,
@@ -69,7 +35,7 @@ module.exports = tseslint.config(
styles: 0
}
],
'no-dashes/no-unicode-dashes': 'error',
'metoyou/no-unicode-symbols': 'error',
'@typescript-eslint/no-extraneous-class': 'off',
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
'@angular-eslint/directive-class-suffix': 'error',
@@ -200,10 +166,10 @@ module.exports = tseslint.config(
// HTML template formatting rules (external Angular templates only)
{
files: ['toju-app/src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin },
plugins: { 'metoyou': metoyouEslintRules },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {
'no-dashes/no-unicode-dashes': 'error',
'metoyou/no-unicode-symbols': 'error',
// Angular template best practices
'@angular-eslint/template/button-has-type': 'warn',
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],

26
package-lock.json generated
View File

@@ -15,8 +15,10 @@
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
@@ -2731,6 +2733,19 @@
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
@@ -5791,6 +5806,17 @@
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",

View File

@@ -65,8 +65,10 @@
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",

43
server/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Server
Node/TypeScript signaling server for MetoYou / Toju. This package owns the public server-directory API, join-request flows, websocket runtime, and server-side persistence.
## Install
1. Run `cd server`.
2. Run `npm install`.
## Commands
- `npm run dev` starts the server with `ts-node-dev` reload.
- `npm run build` compiles TypeScript to `dist/`.
- `npm run start` runs the compiled server.
- From the repository root, `npm run server:dev`, `npm run server:build`, and `npm run server:start` call the same package commands.
## Runtime Config
- The server loads the repository-root `.env` file on startup.
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
- `DB_PATH` can override the SQLite database file location.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
## Structure
| Path | Description |
| --- | --- |
| `src/index.ts` | Bootstrap and server startup |
| `src/app/` | Express app composition |
| `src/routes/` | REST API routes |
| `src/websocket/` | WebSocket runtime and signaling transport |
| `src/cqrs/` | Command/query handlers |
| `src/config/` | Runtime config loading and normalization |
| `src/db/`, `src/entities/`, `src/migrations/` | Persistence layer |
| `data/` | Runtime data files such as `variables.json` |
## Notes
- `dist/` and `../dist-server/` are generated output.
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance.

Binary file not shown.

View File

@@ -12,6 +12,7 @@ export interface LinkPreviewConfig {
export interface ServerVariablesConfig {
klipyApiKey: string;
rawgApiKey: string;
releaseManifestUrl: string;
serverPort: number;
serverProtocol: ServerHttpProtocol;
@@ -31,6 +32,10 @@ function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeRawgApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeReleaseManifestUrl(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
@@ -139,6 +144,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
const normalized = {
...remainingParsed,
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
@@ -153,6 +159,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return {
klipyApiKey: normalized.klipyApiKey,
rawgApiKey: normalized.rawgApiKey,
releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol,
@@ -169,6 +176,14 @@ export function getKlipyApiKey(): string {
return getVariablesConfig().klipyApiKey;
}
export function getRawgApiKey(): string {
if (hasEnvironmentOverride(process.env.RAWG_API_KEY)) {
return process.env.RAWG_API_KEY.trim();
}
return getVariablesConfig().rawgApiKey;
}
export function hasKlipyApiKey(): boolean {
return getKlipyApiKey().length > 0;
}

View File

@@ -1,4 +1,6 @@
import { randomBytes } from 'crypto';
import fs from 'fs';
import fsp from 'fs/promises';
import path from 'path';
import { DataSource } from 'typeorm';
import {
@@ -12,10 +14,25 @@ import {
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
ServerBanEntity,
GameMatchMissEntity
} from '../entities';
import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
import {
findExistingPath,
isPackagedRuntime,
resolvePersistentDataPath,
resolveRuntimePath
} from '../runtime-paths';
const LEGACY_PACKAGED_DB_FILE = path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
const LEGACY_PACKAGED_DB_BACKUP = LEGACY_PACKAGED_DB_FILE + '.bak';
function resolveDefaultDbFile(): string {
return isPackagedRuntime()
? resolvePersistentDataPath('metoyou.sqlite')
: LEGACY_PACKAGED_DB_FILE;
}
function resolveDbFile(): string {
const envPath = process.env.DB_PATH;
@@ -24,7 +41,7 @@ function resolveDbFile(): string {
return path.resolve(envPath);
}
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
return resolveDefaultDbFile();
}
const DB_FILE = resolveDbFile();
@@ -35,6 +52,55 @@ const SQLITE_MAGIC = 'SQLite format 3\0';
let applicationDataSource: DataSource | undefined;
function restoreFromBackup(reason: string): Uint8Array | undefined {
if (!fs.existsSync(DB_BACKUP)) {
console.error(`[DB] ${reason}. No backup available - starting with a fresh database`);
return undefined;
}
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
if (!isValidSqlite(backup)) {
console.error(`[DB] ${reason}. Backup is also invalid - starting with a fresh database`);
return undefined;
}
fs.copyFileSync(DB_BACKUP, DB_FILE);
console.warn('[DB] Restored database from backup', DB_BACKUP);
return backup;
}
async function migrateLegacyPackagedDatabase(): Promise<void> {
if (process.env.DB_PATH || !isPackagedRuntime() || path.resolve(DB_FILE) === path.resolve(LEGACY_PACKAGED_DB_FILE)) {
return;
}
let migrated = false;
if (!fs.existsSync(DB_FILE)) {
if (fs.existsSync(LEGACY_PACKAGED_DB_FILE)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_FILE, DB_FILE);
migrated = true;
} else if (fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_FILE);
migrated = true;
}
}
if (!fs.existsSync(DB_BACKUP) && fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_BACKUP);
migrated = true;
}
if (migrated) {
console.log('[DB] Migrated packaged database files to:', DATA_DIR);
console.log('[DB] Legacy packaged database location was:', LEGACY_PACKAGED_DB_FILE);
}
}
/**
* Returns true when `data` looks like a valid SQLite file
* (correct header magic and at least one complete page).
@@ -54,8 +120,11 @@ function isValidSqlite(data: Uint8Array): boolean {
* restore the backup before the server loads the database.
*/
function safeguardDbFile(): Uint8Array | undefined {
if (!fs.existsSync(DB_FILE))
return undefined;
if (!fs.existsSync(DB_FILE)) {
console.warn(`[DB] ${DB_FILE} is missing - checking backup`);
return restoreFromBackup('Database file missing');
}
const data = new Uint8Array(fs.readFileSync(DB_FILE));
@@ -70,22 +139,7 @@ function safeguardDbFile(): Uint8Array | undefined {
// The main file is corrupt or empty.
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
if (fs.existsSync(DB_BACKUP)) {
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
if (isValidSqlite(backup)) {
fs.copyFileSync(DB_BACKUP, DB_FILE);
console.warn('[DB] Restored database from backup', DB_BACKUP);
return backup;
}
console.error('[DB] Backup is also invalid - starting with a fresh database');
} else {
console.error('[DB] No backup available - starting with a fresh database');
}
return undefined;
return restoreFromBackup(`Database file is invalid (${data.length} bytes)`);
}
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
@@ -101,6 +155,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 {
if (!applicationDataSource?.isInitialized) {
throw new Error('DataSource not initialised');
@@ -113,6 +184,8 @@ export async function initDatabase(): Promise<void> {
if (!fs.existsSync(DATA_DIR))
fs.mkdirSync(DATA_DIR, { recursive: true });
await migrateLegacyPackagedDatabase();
const database = safeguardDbFile();
try {
@@ -130,13 +203,14 @@ export async function initDatabase(): Promise<void> {
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
ServerBanEntity,
GameMatchMissEntity
],
migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true',
logging: false,
autoSave: true,
location: DB_FILE,
autoSaveCallback: atomicSave,
sqlJsConfig: resolveSqlJsConfig()
});
} catch (error) {

View File

@@ -0,0 +1,22 @@
import {
Column,
Entity,
Index,
PrimaryColumn
} from 'typeorm';
@Entity('game_match_misses')
export class GameMatchMissEntity {
@PrimaryColumn('text')
processKey!: string;
@Column('text')
processName!: string;
@Column('integer')
missedAt!: number;
@Index()
@Column('integer')
expiresAt!: number;
}

View File

@@ -9,3 +9,4 @@ export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity';
export { ServerBanEntity } from './ServerBanEntity';
export { GameMatchMissEntity } from './GameMatchMissEntity';

View File

@@ -137,7 +137,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
staleJoinRequestInterval = null;
}
console.log(`\n[Shutdown] ${signal} received - closing database`);
console.log(`\n[Shutdown] ${signal} received - closing database...`);
if (listeningServer?.listening) {
try {

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class GameMatchMisses1000000000006 implements MigrationInterface {
name = 'GameMatchMisses1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "game_match_misses" (
"processKey" TEXT PRIMARY KEY NOT NULL,
"processName" TEXT NOT NULL,
"missedAt" INTEGER NOT NULL,
"expiresAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt"
ON "game_match_misses" ("expiresAt")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
}
}

View File

@@ -4,6 +4,7 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -11,5 +12,6 @@ export const serverMigrations = [
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005
ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006
];

View File

@@ -0,0 +1,17 @@
import { Router } from 'express';
import { matchRunningGames } from '../services/game-matching.service';
const router = Router();
router.post('/match', async (req, res) => {
try {
const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip);
res.json(result);
} catch (error) {
console.error('[Games] Failed to match running games', error);
res.status(500).json({ error: 'Failed to match running games' });
}
});
export default router;

View File

@@ -2,6 +2,7 @@ import { Express } from 'express';
import healthRouter from './health';
import klipyRouter from './klipy';
import linkMetadataRouter from './link-metadata';
import gamesRouter from './games';
import proxyRouter from './proxy';
import usersRouter from './users';
import serversRouter from './servers';
@@ -12,6 +13,7 @@ export function registerRoutes(app: Express): void {
app.use('/api', healthRouter);
app.use('/api', klipyRouter);
app.use('/api', linkMetadataRouter);
app.use('/api/games', gamesRouter);
app.use('/api', proxyRouter);
app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter);

View File

@@ -19,7 +19,8 @@ import {
ServerAccessError,
kickServerUser,
ensureServerMembership,
unbanServerUser
unbanServerUser,
countServerMemberships
} from '../services/server-access.service';
import {
buildAppInviteUrl,
@@ -78,6 +79,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
const owner = await getUserById(server.ownerId);
const userCount = await countServerMemberships(server.id);
const { passwordHash, ...publicServer } = server;
return {
@@ -85,7 +87,8 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
hasPassword: server.hasPassword ?? !!passwordHash,
ownerName: owner?.displayName,
sourceUrl,
userCount: server.currentUsers
currentUsers: userCount,
userCount
};
}

View File

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

View File

@@ -0,0 +1,591 @@
import { getRawgApiKey } from '../config/variables';
import { getDataSource } from '../db/database';
import { GameMatchMissEntity } from '../entities';
export interface MatchedGame {
id: string;
name: string;
iconUrl?: string;
store?: GameStoreLink;
processName: string;
}
export interface GameStoreLink {
id?: string;
name: string;
slug?: string;
domain?: string;
url: string;
}
interface CacheEntry {
expiresAt: number;
game: Omit<MatchedGame, 'processName'> | null;
}
interface RawgSearchResponse {
results?: RawgGameResult[];
}
interface RawgGameResult {
id?: number;
name?: string;
background_image?: string | null;
slug?: string;
stores?: RawgStoreEntry[] | null;
}
interface RawgStoreEntry {
url?: string | null;
store?: RawgStore | null;
}
interface RawgStore {
id?: number;
name?: string;
slug?: string;
domain?: string | null;
}
interface CandidateProcess {
processName: string;
score: number;
}
interface GameMatchResult {
games: MatchedGame[];
rateLimited?: boolean;
}
interface RawgLookupBudget {
used: number;
windowStartedAt: number;
}
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
const MAX_INCOMING_PROCESSES = 256;
const MAX_CANDIDATE_PROCESSES = 24;
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
const MIN_SEARCH_QUERY_LENGTH = 4;
const IGNORED_PROCESS_NAMES = new Set([
'agent',
'bash',
'baloorunner',
'chrome',
'code',
'conhost',
'cursor',
'csrss',
'dbus-daemon',
'discord',
'dwm',
'electron',
'explorer',
'firefox',
'gameoverlayui',
'gamemoded',
'gamescopereaper',
'gnome-shell',
'init',
'kernel_task',
'metoyou',
'nvidia-settings',
'node',
'npm',
'obs',
'powershell',
'pulseaudio',
'services',
'steam',
'steamwebhelper',
'system',
'systemd',
'taskhostw',
'wininit',
'winlogon',
'xorg'
]);
const IGNORED_PROCESS_PATTERNS = [
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
];
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
itch: (query) => `https://itch.io/search?q=${query}`,
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
};
const STORE_SEARCH_ALIASES = new Map<string, string>([
['steam', 'steam'],
['store.steampowered.com', 'steam'],
['epic-games', 'epic-games'],
['store.epicgames.com', 'epic-games'],
['gog', 'gog'],
['www.gog.com', 'gog'],
['gog.com', 'gog'],
['itch', 'itch'],
['itch.io', 'itch'],
['xbox-store', 'xbox-store'],
['www.xbox.com', 'xbox-store'],
['xbox.com', 'xbox-store'],
['playstation-store', 'playstation-store'],
['store.playstation.com', 'playstation-store'],
['nintendo', 'nintendo'],
['www.nintendo.com', 'nintendo'],
['nintendo.com', 'nintendo'],
['apple-appstore', 'apple-appstore'],
['apps.apple.com', 'apple-appstore'],
['google-play', 'google-play'],
['play.google.com', 'google-play']
]);
const STORE_PRIORITY = new Map<string, number>([
['steam', 0],
['gog', 10],
['epic-games', 20],
['itch', 30],
['xbox-store', 80],
['playstation-store', 90]
]);
const cache = new Map<string, CacheEntry>();
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
export async function matchRunningGames(
processNames: unknown,
requester: unknown = 'anonymous'
): Promise<GameMatchResult> {
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
const matches: MatchedGame[] = [];
const seenGameIds = new Set<string>();
const requesterKey = normalizeRequesterKey(requester);
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
let uncachedLookups = 0;
let rateLimited = false;
for (const { processName } of candidates) {
const cacheKey = normalizeCacheKey(processName);
const cached = getCachedGame(cacheKey);
if (cached !== undefined) {
appendMatch(matches, seenGameIds, processName, cached);
continue;
}
if (persistedMisses.has(cacheKey)) {
setCachedGame(cacheKey, null);
continue;
}
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
rateLimited = true;
continue;
}
if (!tryConsumeRawgLookup(requesterKey)) {
rateLimited = true;
continue;
}
uncachedLookups += 1;
const game = await resolveRawgGame(processName);
setCachedGame(cacheKey, game);
if (!game) {
await rememberPersistedMiss(cacheKey, processName);
}
appendMatch(matches, seenGameIds, processName, game);
}
return {
games: matches,
rateLimited: rateLimited || undefined
};
}
function normalizeProcessList(value: unknown): CandidateProcess[] {
if (!Array.isArray(value)) {
return [];
}
const processes = new Map<string, CandidateProcess>();
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
const processName = normalizeProcessName(entry);
if (processName) {
const cacheKey = normalizeCacheKey(processName);
if (!processes.has(cacheKey)) {
processes.set(cacheKey, {
processName,
score: scoreCandidateProcess(String(entry), processName)
});
}
}
}
return Array.from(processes.values())
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
}
function normalizeProcessName(value: unknown): string {
if (typeof value !== 'string') {
return '';
}
const normalized = value
.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const cacheKey = normalizeCacheKey(normalized);
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
return '';
}
return normalized;
}
function shouldIgnoreProcessName(cacheKey: string): boolean {
return IGNORED_PROCESS_NAMES.has(cacheKey)
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
}
function normalizeRequesterKey(value: unknown): string {
if (typeof value !== 'string') {
return 'anonymous';
}
const normalized = value.trim().toLowerCase();
return normalized || 'anonymous';
}
function tryConsumeRawgLookup(requesterKey: string): boolean {
const now = Date.now();
const existing = rawgLookupBudgets.get(requesterKey);
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
rawgLookupBudgets.set(requesterKey, {
used: 1,
windowStartedAt: now
});
return true;
}
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
return false;
}
existing.used += 1;
return true;
}
function scoreCandidateProcess(rawValue: string, processName: string): number {
let score = 0;
if (/\.exe$/i.test(rawValue.trim())) {
score += 12;
}
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
score += 4;
}
if (/\d/.test(processName)) {
score += 1;
}
if (processName.length >= 5 && processName.length <= 32) {
score += 2;
}
if (processName.includes(' ')) {
score -= 2;
}
return score;
}
function normalizeCacheKey(value: string): string {
return value.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
const cached = cache.get(cacheKey);
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
cache.delete(cacheKey);
return undefined;
}
return cached.game;
}
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
cache.set(cacheKey, {
expiresAt: Date.now() + CACHE_TTL_MS,
game
});
}
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
if (cacheKeys.length === 0) {
return new Set();
}
try {
const repository = getDataSource().getRepository(GameMatchMissEntity);
const now = Date.now();
await repository.createQueryBuilder()
.delete()
.where('expiresAt <= :now', { now })
.execute();
const rows = await repository.createQueryBuilder('miss')
.select('miss.processKey')
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
.andWhere('miss.expiresAt > :now', { now })
.getMany();
return new Set(rows.map((row) => row.processKey));
} catch {
return new Set();
}
}
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
try {
const now = Date.now();
await getDataSource().getRepository(GameMatchMissEntity)
.save({
processKey: cacheKey,
processName,
missedAt: now,
expiresAt: now + PERSISTED_MISS_TTL_MS
});
} catch {
return;
}
}
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
const apiKey = getRawgApiKey();
if (!apiKey) {
return null;
}
const query = buildSearchQuery(processName);
if (!query) {
return null;
}
const url = new URL(RAWG_SEARCH_URL);
url.searchParams.set('key', apiKey);
url.searchParams.set('search', query);
url.searchParams.set('search_precise', 'true');
url.searchParams.set('exclude_additions', 'true');
url.searchParams.set('page_size', '1');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
return null;
}
const body = await response.json() as RawgSearchResponse;
const result = body.results?.[0];
if (!isAcceptableRawgMatch(query, result)) {
return null;
}
return {
id: String(result.id),
name: result.name.trim(),
iconUrl: result.background_image || undefined,
store: selectPreferredStore(result, result.name.trim())
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
const stores = Array.isArray(result.stores) ? result.stores : [];
const usableStores = stores
.map((entry) => buildStoreLink(entry, gameName))
.filter((store): store is GameStoreLink => !!store);
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
}
function getStorePriority(store: GameStoreLink): number {
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
?? store.name.trim().toLowerCase();
return STORE_PRIORITY.get(storeKey) ?? 50;
}
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
const store = entry.store;
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
return undefined;
}
const slug = typeof store.slug === 'string' && store.slug.trim()
? store.slug.trim().toLowerCase()
: undefined;
const domain = typeof store.domain === 'string' && store.domain.trim()
? store.domain.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/$/, '')
: undefined;
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
if (!url) {
return undefined;
}
return {
id: typeof store.id === 'number' ? String(store.id) : undefined,
name: store.name.trim(),
slug,
domain,
url
};
}
function normalizeExternalUrl(value: unknown): string | undefined {
if (typeof value !== 'string' || !value.trim()) {
return undefined;
}
const trimmed = value.trim();
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: undefined;
}
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
const query = encodeURIComponent(gameName);
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
}
function buildSearchQuery(processName: string): string {
const query = processName
.replace(/\.exe$/i, '')
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
}
function isAcceptableRawgMatch(
query: string,
result: RawgGameResult | undefined
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
return false;
}
const queryKey = normalizeComparableText(query);
const nameKey = normalizeComparableText(result.name);
const slugKey = normalizeComparableText(result.slug ?? '');
const queryTokens = tokenizeComparableText(queryKey);
const nameTokens = tokenizeComparableText(nameKey);
const slugTokens = tokenizeComparableText(slugKey);
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
return false;
}
if (queryKey === nameKey || queryKey === slugKey) {
return true;
}
if (queryTokens.length === 1) {
const [queryToken] = queryTokens;
return queryToken.length >= 5
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
}
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
}
function normalizeComparableText(value: string): string {
return value.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokenizeComparableText(value: string): string[] {
return value.split(' ')
.filter((token) => token.length >= 2);
}
function appendMatch(
matches: MatchedGame[],
seenGameIds: Set<string>,
processName: string,
game: Omit<MatchedGame, 'processName'> | null
): void {
if (!game || seenGameIds.has(game.id)) {
return;
}
seenGameIds.add(game.id);
matches.push({
...game,
processName
});
}

View File

@@ -130,6 +130,10 @@ export async function findServerMembership(serverId: string, userId: string): Pr
return await getMembershipRepository().findOne({ where: { serverId, userId } });
}
export async function countServerMemberships(serverId: string): Promise<number> {
return await getMembershipRepository().count({ where: { serverId } });
}
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
const repo = getMembershipRepository();
const now = Date.now();

View File

@@ -2,13 +2,18 @@ import {
describe,
it,
expect,
beforeEach
beforeEach,
vi
} from 'vitest';
import { connectedUsers } from './state';
import { handleWebSocketMessage } from './handler';
import { ConnectedUser } from './types';
import { WebSocket } from 'ws';
vi.mock('../services/server-access.service', () => ({
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
}));
/**
* Minimal mock WebSocket that records sent messages.
*/
@@ -62,6 +67,14 @@ describe('server websocket handler - status_update', () => {
connectedUsers.clear();
});
it('treats signaling keepalive messages as connection liveness', async () => {
createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
await handleWebSocketMessage('conn-1', { type: 'keepalive' });
expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1);
});
it('updates user status on valid status_update message', async () => {
const user = createConnectedUser('conn-1', 'user-1');
@@ -149,7 +162,7 @@ describe('server websocket handler - status_update', () => {
// Identify first (required for handler)
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
// user-2 joins server should receive server_users with user-1's status
// user-2 joins server -> should receive server_users with user-1's status
getSentMessagesStore(user2).sentMessages.length = 0;
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
@@ -197,3 +210,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);
});
});

View File

@@ -20,6 +20,22 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
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 {
if (typeof value !== 'string') {
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. */
function sendServerUsers(user: ConnectedUser, serverId: string): void {
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 }));
}
@@ -45,31 +67,44 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const newOderId = readMessageId(message['oderId']) ?? connectionId;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
// Close stale connections from the same identity AND the same connection
// scope so offer routing always targets the freshest socket (e.g. after
// page refresh). Connections with a *different* scope (= a different
// signal URL that happens to route to this server) are left untouched so
// multi-signal-URL setups don't trigger an eviction loop.
connectedUsers.forEach((existing, existingId) => {
if (existingId !== connectionId
&& existing.oderId === newOderId
&& existing.connectionScope === newScope) {
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
try {
existing.ws.close();
} catch { /* already closing */ }
connectedUsers.delete(existingId);
}
});
const previousDisplayName = normalizeDisplayName(user.displayName);
const previousDescription = user.description;
const previousProfileUpdatedAt = user.profileUpdatedAt;
user.oderId = newOderId;
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;
connectedUsers.set(connectionId, user);
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> {
@@ -108,6 +143,8 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
type: 'user_joined',
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
status: user.status ?? 'online',
serverId: sid
}, user.oderId);
@@ -220,7 +257,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
user.status = status as ConnectedUser['status'];
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) {
broadcastToServer(serverId, {
@@ -237,7 +274,13 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
if (!user)
return;
user.lastPong = Date.now();
connectedUsers.set(connectionId, user);
switch (message.type) {
case 'keepalive':
break;
case 'identify':
handleIdentify(user, message, connectionId);
break;

View File

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

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

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

View File

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

View File

@@ -28,7 +28,7 @@
@if (themeStudioFullscreenComponent()) {
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
} @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>
} @else { @if (showDesktopUpdateNotice()) {
@@ -145,7 +145,7 @@
</button>
</div>
</div>
} @if (!isThemeStudioFullscreen()) {
} @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
<app-floating-voice-controls />
}
<app-settings-modal />

View File

@@ -34,6 +34,16 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
},
{
path: 'dm',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'dm/:conversationId',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'settings',
loadComponent: () =>

View File

@@ -34,6 +34,7 @@ import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.service';
import { GameActivityService } from './domains/game-activity';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -44,7 +45,8 @@ import { NativeContextMenuComponent } from './features/shell/native-context-menu
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants';
import { ROOM_URL_PATTERN } from './core/constants';
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
import {
ThemeNodeDirective,
ThemePickerOverlayComponent,
@@ -94,10 +96,12 @@ export class App implements OnInit, OnDestroy {
readonly externalLinks = inject(ExternalLinkService);
readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
readonly isDraggingThemeStudioControls = signal(false);
readonly currentRouteUrl = signal(this.getCurrentRouteUrl());
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
@@ -111,6 +115,7 @@ export class App implements OnInit, OnDestroy {
return this.settingsModal.activePage() === 'theme'
&& this.settingsModal.themeStudioMinimized();
});
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
readonly desktopUpdateNoticeKey = computed(() => {
const updateState = this.desktopUpdateState();
@@ -219,8 +224,19 @@ export class App implements OnInit, OnDestroy {
void this.desktopUpdates.initialize();
let currentUserId = getStoredCurrentUserId();
await this.databaseService.initialize();
if (currentUserId) {
const persistedUserId = await this.databaseService.getCurrentUserId();
if (persistedUserId !== currentUserId) {
clearStoredCurrentUserId();
currentUserId = null;
}
}
try {
const apiBase = this.servers.getApiBaseUrl();
@@ -231,31 +247,29 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());
this.userStatus.start();
this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
this.gameActivity.start();
const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) {
if (!this.isPublicRoute(this.router.url)) {
if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
returnUrl: currentUrl
}
}).catch(() => {});
}
} else {
const current = this.router.url;
this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
const generalSettings = loadGeneralSettingsFromStorage();
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
if (
generalSettings.reopenLastViewedChat
&& lastViewedChat
&& (current === '/' || current === '/search')
&& (currentUrl === '/' || currentUrl === '/search')
) {
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
}
@@ -264,6 +278,8 @@ export class App implements OnInit, OnDestroy {
this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
this.currentRouteUrl.set(url);
const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null;
@@ -388,9 +404,31 @@ export class App implements OnInit, OnDestroy {
}
private isPublicRoute(url: string): boolean {
return url === '/login' ||
url === '/register' ||
url.startsWith('/invite/');
const path = this.getRoutePath(url);
return path === '/login' ||
path === '/register' ||
path.startsWith('/invite/');
}
private getCurrentRouteUrl(): string {
if (typeof window === 'undefined') {
return this.router.url;
}
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
return currentUrl || this.router.url;
}
private getRoutePath(url: string): string {
if (!url) {
return '/';
}
const [path] = url.split(/[?#]/, 1);
return path || '/';
}
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {

View File

@@ -5,6 +5,7 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_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_DRAFT = 'metoyou_theme_draft';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';

View File

@@ -124,6 +124,24 @@ export interface SavedThemeFileDescriptor {
path: string;
}
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
export interface ElectronCommand {
type: string;
payload: unknown;
@@ -157,6 +175,7 @@ export interface ElectronApi {
closeWindow: () => void;
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
@@ -165,6 +184,10 @@ export interface ElectronApi {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>;

View File

@@ -1508,7 +1508,7 @@ class DebugNetworkSnapshotBuilder {
if (value.length <= 12)
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 {

View File

@@ -7,6 +7,7 @@ export type SettingsPage =
| 'notifications'
| 'voice'
| 'updates'
| 'data'
| 'debugging'
| 'server'
| 'members'

View File

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

View File

@@ -12,6 +12,8 @@ infrastructure adapters and UI.
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
@@ -28,6 +30,7 @@ The larger domains also keep longer design notes in their own folders:
- [access-control/README.md](access-control/README.md)
- [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md)
- [direct-message/README.md](direct-message/README.md)
- [notifications/README.md](notifications/README.md)
- [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md)

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,11 @@ import { UserAvatarComponent } from '../../../../shared';
@Component({
selector: 'app-user-bar',
standalone: true,
imports: [CommonModule, NgIcon, UserAvatarComponent],
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideLogIn,

View File

@@ -1,8 +1,5 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -13,7 +10,11 @@ import {
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryFacade } from '../../../server-directory';
import {
ServerDirectoryFacade,
type RoomSignalSourceInput,
type ServerSourceSelector
} from '../../../server-directory';
export interface KlipyGif {
id: string;
@@ -37,51 +38,47 @@ export interface KlipyGifSearchResponse {
const DEFAULT_PAGE_SIZE = 24;
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
const DEFAULT_AVAILABILITY_KEY = 'default';
interface KlipyAvailabilityState {
enabled: boolean;
loading: boolean;
}
@Injectable({ providedIn: 'root' })
export class KlipyService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly availabilityState = signal({
enabled: false,
loading: true
});
private lastAvailabilityKey = '';
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
readonly isEnabled = computed(() => this.availabilityState().enabled);
readonly isLoading = computed(() => this.availabilityState().loading);
constructor() {
effect(() => {
const activeServer = this.serverDirectory.activeServer();
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
if (nextKey === this.lastAvailabilityKey)
return;
this.lastAvailabilityKey = nextKey;
void this.refreshAvailability();
});
isEnabled(source?: RoomSignalSourceInput | null): boolean {
return this.getAvailabilityState(source).enabled;
}
async refreshAvailability(): Promise<void> {
this.availabilityState.set({ enabled: false,
isLoading(source?: RoomSignalSourceInput | null): boolean {
return this.getAvailabilityState(source).loading;
}
async refreshAvailability(source?: RoomSignalSourceInput | null): Promise<void> {
const selector = this.getSourceSelector(source);
const key = this.getAvailabilityKey(selector);
this.setAvailabilityState(key, { enabled: false,
loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
)
);
this.availabilityState.set({
this.setAvailabilityState(key, {
enabled: response.enabled === true,
loading: false
});
} catch {
this.availabilityState.set({ enabled: false,
this.setAvailabilityState(key, { enabled: false,
loading: false });
}
}
@@ -89,8 +86,11 @@ export class KlipyService {
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE
perPage = DEFAULT_PAGE_SIZE,
source?: RoomSignalSourceInput | null
): Observable<KlipyGifSearchResponse> {
const selector = this.getSourceSelector(source);
let params = new HttpParams()
.set('page', String(Math.max(1, Math.floor(page))))
.set('per_page', String(Math.max(1, Math.floor(perPage))))
@@ -109,7 +109,7 @@ export class KlipyService {
}
return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
@@ -138,7 +138,7 @@ export class KlipyService {
return this.normalizeMediaUrl(url);
}
buildImageProxyUrl(url: string): string {
buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)
@@ -147,7 +147,36 @@ export class KlipyService {
if (!/^https?:\/\//i.test(trimmed))
return trimmed;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
?? { enabled: false,
loading: true };
}
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
this.availabilityByKey.update((availabilityByKey) => ({
...availabilityByKey,
[key]: state
}));
}
private getSourceSelector(source?: RoomSignalSourceInput | null): ServerSourceSelector | undefined {
return this.serverDirectory.buildRoomSignalSelector(source ?? undefined);
}
private getAvailabilityKey(selector?: ServerSourceSelector): string {
if (selector?.sourceId) {
return `id:${selector.sourceId}`;
}
if (selector?.sourceUrl) {
return `url:${selector.sourceUrl}`;
}
return DEFAULT_AVAILABILITY_KEY;
}
private getPreferredLocale(): string | null {

View File

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

View File

@@ -8,6 +8,7 @@ import {
signal
} from '@angular/core';
import { KlipyService } from '../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../server-directory';
@Directive({
selector: 'img[appChatImageProxyFallback]',
@@ -15,6 +16,7 @@ import { KlipyService } from '../application/services/klipy.service';
})
export class ChatImageProxyFallbackDirective {
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
readonly signalSource = input<RoomSignalSourceInput | null>(null);
private readonly klipy = inject(KlipyService);
private readonly renderedSource = signal('');
@@ -38,7 +40,7 @@ export class ChatImageProxyFallbackDirective {
return;
}
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource());
if (!proxyUrl || proxyUrl === this.renderedSource()) {
return;

View File

@@ -1,4 +1,7 @@
<div class="chat-layout relative h-full">
<div
appThemeNode="chatSurface"
class="chat-layout relative h-full"
>
<app-chat-message-list
[allMessages]="allMessages()"
[channelMessages]="channelMessages()"
@@ -19,10 +22,15 @@
(embedRemoved)="handleEmbedRemoved($event)"
/>
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
>
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
[klipyEnabled]="klipyEnabled()"
[klipySignalSource]="currentRoom()"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
@@ -45,11 +53,13 @@
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>

View File

@@ -4,6 +4,7 @@ import {
HostListener,
ViewChild,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -11,7 +12,7 @@ import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif } from '../../application/services/klipy.service';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
selectAllMessages,
@@ -21,6 +22,7 @@ import {
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../theme';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
@@ -42,7 +44,8 @@ import {
ChatMessageComposerComponent,
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent
ChatMessageOverlaysComponent,
ThemeNodeDirective
],
templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss'
@@ -54,10 +57,11 @@ export class ChatMessagesComponent {
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
@@ -68,16 +72,11 @@ export class ChatMessagesComponent {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(
(message) =>
message.roomId === roomId &&
(message.channelId || 'general') === channelId
);
return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
});
readonly conversationKey = computed(
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
@@ -85,6 +84,12 @@ export class ChatMessagesComponent {
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
constructor() {
effect(() => {
void this.klipy.refreshAvailability(this.currentRoom());
});
}
@HostListener('window:resize')
onWindowResize(): void {
if (this.showKlipyGifPicker()) {
@@ -167,9 +172,7 @@ export class ChatMessagesComponent {
if (!message || !currentUserId)
return;
const hasReacted = message.reactions.some(
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
);
const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
if (hasReacted) {
this.store.dispatch(
@@ -234,9 +237,7 @@ export class ChatMessagesComponent {
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set(
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
}
private getKlipyGifPickerWidth(viewportWidth: number): number {
@@ -281,10 +282,7 @@ export class ChatMessagesComponent {
if (blob) {
try {
const result = await electronApi.saveFileAs(
attachment.filename,
await this.blobToBase64(blob)
);
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled)
return;
@@ -407,12 +405,7 @@ export class ChatMessagesComponent {
const message = [...this.channelMessages()]
.reverse()
.find(
(entry) =>
entry.senderId === currentUserId &&
entry.content === content &&
!entry.isDeleted
);
.find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
if (!message) {
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);

View File

@@ -1,7 +1,13 @@
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<div #composerRoot>
<div
#composerRoot
appThemeNode="chatComposerBar"
>
@if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
<div
appThemeNode="chatComposerReplyBar"
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
>
<ng-icon
name="lucideReply"
class="h-4 w-4 text-muted-foreground"
@@ -31,6 +37,7 @@
(mouseleave)="onToolbarMouseLeave()"
>
<div
appThemeNode="chatComposerToolbar"
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
>
<button
@@ -124,6 +131,7 @@
<div class="border-border p-4">
<div
appThemeNode="chatComposerInput"
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
@@ -133,7 +141,7 @@
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@if (klipy.isEnabled()) {
@if (klipyEnabled()) {
<button
#klipyTrigger
type="button"
@@ -156,6 +164,7 @@
}
<button
appThemeNode="chatComposerSendButton"
type="button"
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
@@ -172,6 +181,7 @@
<textarea
#messageInputRef
[attr.data-testid]="textareaTestId()"
rows="1"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
@@ -189,8 +199,8 @@
[class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
[class.pr-16]="!klipyEnabled()"
[class.pr-40]="klipyEnabled()"
></textarea>
@if (dragActive()) {
@@ -207,6 +217,7 @@
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
[signalSource]="klipySignalSource()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover"
loading="lazy"

View File

@@ -23,6 +23,8 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
@@ -42,7 +44,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
FormsModule,
NgIcon,
ChatImageProxyFallbackDirective,
TypingIndicatorComponent
TypingIndicatorComponent,
ThemeNodeDirective
],
viewProviders: [
provideIcons({
@@ -66,6 +69,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false);
readonly klipyEnabled = input(false);
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
readonly textareaTestId = input<string | null>(null);
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output();
@@ -73,7 +79,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = output();
readonly klipy = inject(KlipyService);
private readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
@@ -207,7 +213,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
toggleKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
if (!this.klipyEnabled())
return;
this.klipyGifPickerToggleRequested.emit();
@@ -411,11 +417,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
private hasPotentialFilePayload(
dataTransfer: DataTransfer | null,
treatMissingTypesAsPotentialFile = true
): boolean {
private hasPotentialFilePayload(dataTransfer: DataTransfer | null, treatMissingTypesAsPotentialFile = true): boolean {
if (!dataTransfer)
return false;

View File

@@ -2,11 +2,13 @@
@let msg = message();
@let attachmentsList = attachmentViewModels();
<div
appThemeNode="chatMessageBubble"
[attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted"
>
<div
appThemeNode="chatMessageAvatar"
class="flex-shrink-0 cursor-pointer"
(click)="openSenderProfileCard($event); $event.stopPropagation()"
>
@@ -17,7 +19,10 @@
/>
</div>
<div class="min-w-0 flex-1">
<div
appThemeNode="chatMessageContent"
class="min-w-0 flex-1"
>
@if (msg.replyToId) {
@let reply = repliedMessage();
<div
@@ -100,6 +105,7 @@
@if (msg.linkMetadata?.length) {
@for (meta of msg.linkMetadata; track meta.url) {
@if (shouldShowLinkEmbed(meta.url)) {
<app-chat-link-embed
[metadata]="meta"
[canRemove]="isOwnMessage() || isAdmin()"
@@ -107,6 +113,7 @@
/>
}
}
}
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@@ -148,7 +155,10 @@
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
@@ -170,7 +180,10 @@
</div>
</div>
} @else {
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon
@@ -188,7 +201,7 @@
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source' }}
{{ att.requestError || 'Waiting for image source...' }}
</div>
</div>
</div>
@@ -218,7 +231,10 @@
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
@@ -245,7 +261,10 @@
</div>
</div>
} @else {
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
@@ -269,7 +288,10 @@
</div>
}
} @else {
<div class="rounded-md border border-border bg-secondary/40 p-2">
<div
appThemeNode="chatAttachmentCard"
class="rounded-md border border-border bg-secondary/40 p-2"
>
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
@@ -337,6 +359,7 @@
<div class="mt-2 flex flex-wrap gap-1">
@for (reaction of getGroupedReactions(); track reaction.emoji) {
<button
appThemeNode="chatReactionPill"
(click)="toggleReaction(reaction.emoji)"
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
[class.ring-1]="reaction.hasCurrentUser"
@@ -352,6 +375,7 @@
@if (!msg.isDeleted) {
<div
appThemeNode="chatMessageActions"
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="relative">

View File

@@ -31,11 +31,13 @@ import {
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment';
import { KlipyService } from '../../../../application/services/klipy.service';
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
import {
DELETED_MESSAGE_CONTENT,
Message,
User
} from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import {
ChatAudioPlayerComponent,
@@ -95,7 +97,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatVideoPlayerComponent,
ChatMessageMarkdownComponent,
ChatLinkEmbedComponent,
UserAvatarComponent
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [
provideIcons({
@@ -149,7 +152,8 @@ export class ChatMessageItemComponent {
const msg = this.message();
const found = this.userLookup().get(msg.senderId);
return found ?? {
return (
found ?? {
id: msg.senderId,
oderId: msg.senderId,
username: msg.senderName,
@@ -157,7 +161,8 @@ export class ChatMessageItemComponent {
status: 'disconnected',
role: 'member',
joinedAt: 0
};
}
);
});
editContent = '';
@@ -174,9 +179,7 @@ export class ChatMessageItemComponent {
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion();
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
this.buildAttachmentViewModel(attachment)
);
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
});
private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated();
@@ -278,6 +281,10 @@ export class ChatMessageItemComponent {
});
}
shouldShowLinkEmbed(url?: string): boolean {
return !hasDedicatedChatEmbed(url);
}
requestReferenceScroll(messageId: string): void {
this.referenceRequested.emit(messageId);
}
@@ -315,8 +322,7 @@ export class ChatMessageItemComponent {
hour: '2-digit',
minute: '2-digit'
});
const toDay = (value: Date) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const toDay = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0)
@@ -326,11 +332,7 @@ export class ChatMessageItemComponent {
return 'Yesterday ' + time;
if (dayDiff < 7) {
return (
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
time
);
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
}
return (
@@ -397,10 +399,7 @@ export class ChatMessageItemComponent {
}
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
return (
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
);
return (this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
}
getMediaAttachmentStatusText(attachment: Attachment): string {
@@ -413,9 +412,7 @@ export class ChatMessageItemComponent {
: 'Large audio file. Accept the download to play it in chat.';
}
return this.isVideoAttachment(attachment)
? 'Waiting for video source…'
: 'Waiting for audio source…';
return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
@@ -479,8 +476,7 @@ export class ChatMessageItemComponent {
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment);
const requiresMediaDownloadAcceptance =
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
return {
...attachment,
@@ -488,8 +484,12 @@ export class ChatMessageItemComponent {
isUploader: this.isUploader(attachment),
isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError ? 'Retry download' : 'Accept download'
: attachment.requestError ? 'Retry' : 'Request',
? attachment.requestError
? 'Retry download'
: 'Accept download'
: attachment.requestError
? 'Retry'
: 'Request',
mediaStatusText: attachment.requestError
? attachment.requestError
: requiresMediaDownloadAcceptance
@@ -497,17 +497,13 @@ export class ChatMessageItemComponent {
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.'
: isVideo
? 'Waiting for video source'
: 'Waiting for audio source',
progressPercent: attachment.size > 0
? ((attachment.receivedBytes || 0) * 100) / attachment.size
: 0
? 'Waiting for video source...'
: 'Waiting for audio source...',
progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
};
}
private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc
.getForMessage(this.message().id)
.find((attachment) => attachment.id === attachmentId);
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
}
}

View File

@@ -45,6 +45,14 @@
<div class="block">
<app-chat-youtube-embed [url]="node.url" />
</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>
</remark>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
<iframe
[src]="embedUrl()"
class="aspect-video w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
></iframe>

View File

@@ -5,8 +5,21 @@ import {
input
} from '@angular/core';
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({
selector: 'app-chat-youtube-embed',
@@ -16,11 +29,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y
export class ChatYoutubeEmbedComponent {
readonly url = input.required<string>();
readonly videoId = computed(() => {
const match = this.url().match(YOUTUBE_URL_PATTERN);
return match?.[1] ?? null;
});
readonly videoId = computed(() => extractYoutubeVideoId(this.url()));
readonly embedUrl = computed(() => {
const id = this.videoId();
@@ -28,14 +37,16 @@ export class ChatYoutubeEmbedComponent {
if (!id)
return '';
const clientOrigin = resolveYoutubeClientOrigin();
const embedUrl = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`);
embedUrl.searchParams.set('origin', clientOrigin);
embedUrl.searchParams.set('widget_referrer', clientOrigin);
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
embedUrl.toString()
);
});
private readonly sanitizer = inject(DomSanitizer);
}
export function isYoutubeUrl(url?: string): boolean {
return !!url && YOUTUBE_URL_PATTERN.test(url);
}

View File

@@ -1,5 +1,6 @@
<div
#messagesContainer
appThemeNode="chatMessageList"
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
[style.padding-bottom.px]="bottomPadding()"
(scroll)="onScroll()"
@@ -7,7 +8,7 @@
@if (syncing() && !loading()) {
<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>
<span>Syncing messages</span>
<span>Syncing messages...</span>
</div>
}
@@ -39,7 +40,10 @@
@for (message of messages(); track message.id; let index = $index) {
@if (dateSeparatorLabels().get(index); as separatorLabel) {
<div class="flex items-center gap-3 py-1">
<div
appThemeNode="chatDateSeparator"
class="flex items-center gap-3 py-1"
>
<div class="h-px flex-1 bg-border"></div>
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
{{ separatorLabel }}
@@ -70,7 +74,10 @@
@if (showNewMessagesBar()) {
<div class="pointer-events-none sticky bottom-4 flex justify-center">
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
<div
appThemeNode="chatNewMessagesBar"
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
>
<span class="text-sm text-muted-foreground">New messages</span>
<button
type="button"

View File

@@ -26,6 +26,7 @@ import {
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal {
@@ -41,7 +42,11 @@ declare global {
@Component({
selector: 'app-chat-message-list',
standalone: true,
imports: [CommonModule, ChatMessageItemComponent],
imports: [
CommonModule,
ChatMessageItemComponent,
ThemeNodeDirective
],
templateUrl: './chat-message-list.component.html',
host: {
style: 'display: contents;'
@@ -66,6 +71,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
readonly isAdmin = input(false);
readonly bottomPadding = input(120);
readonly conversationKey = input.required<string>();
readonly userLookupOverrides = input<User[]>([]);
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
@@ -93,9 +99,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return all.slice(all.length - limit);
});
readonly hasMoreMessages = computed(
() => this.channelMessages().length > this.displayLimit()
);
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
readonly dateSeparatorLabels = computed(() => {
const labels = new Map<number, string>();
@@ -126,6 +130,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}
}
for (const user of this.userLookupOverrides()) {
lookup.set(user.id, user);
if (user.oderId && user.oderId !== user.id) {
lookup.set(user.oderId, user);
}
}
return lookup;
});
@@ -156,8 +168,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return;
}
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
if (newMessages) {
@@ -219,8 +230,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (!element || this.isAutoScrolling)
return;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const shouldStickToBottom = distanceFromBottom <= 300;
if (shouldStickToBottom) {
@@ -377,11 +387,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener(
'load',
this.boundOnImageLoad,
true
);
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null;
}

View File

@@ -62,7 +62,7 @@
@if (loading() && results().length === 0) {
<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>
<p class="text-sm">Loading GIFs from KLIPY</p>
<p class="text-sm">Loading GIFs from KLIPY...</p>
</div>
} @else if (results().length === 0) {
<div
@@ -93,6 +93,7 @@
>
<img
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
[signalSource]="signalSource()"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
@@ -125,7 +126,7 @@
[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"
>
{{ loading() ? 'Loading' : 'Load more' }}
{{ loading() ? 'Loading...' : 'Load more' }}
</button>
}
</div>

View File

@@ -8,6 +8,7 @@ import {
OnInit,
ViewChild,
inject,
input,
output,
signal
} from '@angular/core';
@@ -21,6 +22,7 @@ import {
lucideX
} from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
const KLIPY_CARD_MIN_WIDTH = 140;
@@ -48,6 +50,8 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
templateUrl: './klipy-gif-picker.component.html'
})
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
readonly signalSource = input<RoomSignalSourceInput | null>(null);
readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
@@ -128,7 +132,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage)
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
);
if (requestId !== this.requestId)

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