Compare commits

...

15 Commits

Author SHA1 Message Date
Myx
b10004e154 test: Add playwright main usage test
Some checks failed
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Has started running
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
2026-04-11 16:48:26 +02:00
Myx
f33440a827 test: Initate instructions 2026-04-11 15:38:16 +02:00
Myx
52912327ae refactor: stricter domain: voice-session 2026-04-11 15:12:54 +02:00
Myx
7a0664b3c4 refactor: stricter domain: voice-connection 2026-04-11 15:08:04 +02:00
Myx
cea3dccef1 refactor: stricter domain: theme 2026-04-11 15:01:39 +02:00
Myx
c8bb82feb5 refactor: stricter domain: server-directory 2026-04-11 14:50:38 +02:00
Myx
3fb5515c3a refactor: stricter domain: screen-share 2026-04-11 14:42:12 +02:00
Myx
a6bdac1a25 refactor: true facades 2026-04-11 14:35:02 +02:00
Myx
db7e683504 refactor: stricer domain: notifications 2026-04-11 14:23:50 +02:00
Myx
98ed8eeb68 refactor: stricter domain: chat 2026-04-11 14:01:19 +02:00
Myx
39b85e2e3a refactor: stricter domain: auth 2026-04-11 13:52:59 +02:00
Myx
58e338246f refactor: stricter domain: attachments 2026-04-11 13:39:27 +02:00
Myx
0b9a9f311e refactor: stricter domain: access-control 2026-04-11 13:31:25 +02:00
Myx
6800c73292 refactor: Cleaning rooms store 2026-04-11 13:07:46 +02:00
Myx
ef1182d46f fix: Broken voice states and connectivity drops 2026-04-11 12:32:22 +02:00
170 changed files with 5608 additions and 1944 deletions

View File

@@ -0,0 +1,63 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
wenyan-lite, wenyan-full, wenyan-ultra.
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
---
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Default: **full**. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | What change |
|-------|------------|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
- wenyan-full: "物出新參照致重繪。useMemo .Wrap之。"
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
Example — "Explain database connection pooling."
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
## Auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
> DROP TABLE users;
> ```
> Caveman resume. Verify backup exist first.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.

View File

@@ -0,0 +1,282 @@
---
name: playwright-e2e
description: >
Write and run Playwright E2E tests for MetoYou (Angular chat + WebRTC voice/video app).
Handles multi-browser-context WebRTC voice testing, fake media devices, signaling validation,
audio channel verification, and UI state assertions. Use when: "E2E test", "Playwright",
"end-to-end", "browser test", "test voice", "test call", "test WebRTC", "test chat",
"test login", "test video", "test screen share", "integration test", "multi-client test".
---
# Playwright E2E Testing — MetoYou
## Step 1 — Check Project Setup
Before writing any test, verify the Playwright infrastructure exists:
```
e2e/ # Test root (lives at repo root)
├── playwright.config.ts # Config
├── fixtures/ # Custom fixtures (multi-client, auth, etc.)
├── pages/ # Page Object Models
├── tests/ # Test specs
│ ├── auth/
│ ├── chat/
│ ├── voice/
│ └── settings/
└── helpers/ # Shared utilities (WebRTC introspection, etc.)
```
If missing, scaffold it. See [reference/project-setup.md](./reference/project-setup.md).
## Step 2 — Identify Test Category
| Request | Category | Key Patterns |
|---------|----------|-------------|
| Login, register, invite | **Auth** | Single browser context, form interaction |
| Send message, rooms, chat UI | **Chat** | May need 2 clients for real-time sync |
| Voice call, mute, deafen, audio | **Voice/WebRTC** | Multi-client, fake media, WebRTC introspection |
| Camera, video tiles | **Video** | Multi-client, fake video, stream validation |
| Screen share | **Screen Share** | Multi-client, display media mocking |
| Settings, themes | **Settings** | Single client, preference persistence |
For **Voice/WebRTC** and **Multi-client** tests, read [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) immediately.
## Step 3 — Core Conventions
### Config Essentials
```typescript
// e2e/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000, // WebRTC needs longer timeouts
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1, // Sequential — shared server state
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
permissions: ['microphone', 'camera'],
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
// Feed a specific audio file as fake mic input:
// '--use-file-for-fake-audio-capture=/path/to/audio.wav',
],
},
},
},
],
webServer: [
{
command: 'cd server && npm run dev',
port: 3001,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
{
command: 'cd toju-app && npx ng serve',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
],
});
```
### Selector Strategy
Use in this order — stop at the first that works:
1. `getByRole('button', { name: 'Mute' })` — accessible, resilient
2. `getByLabel('Email')` — form fields
3. `getByPlaceholder('Enter email')` — when label missing
4. `getByText('Welcome')` — visible text
5. `getByTestId('voice-controls')` — last resort, needs `data-testid`
6. `locator('app-voice-controls')` — Angular component selectors (acceptable in this project)
Angular component selectors (`app-*`) are stable in this project and acceptable as locators when semantic selectors are not feasible.
### Assertions — Always Web-First
```typescript
// ✅ Auto-retries until timeout
await expect(page.getByRole('heading')).toBeVisible();
await expect(page.getByRole('alert')).toHaveText('Saved');
await expect(page).toHaveURL(/\/room\//);
// ❌ No auto-retry — races with DOM
const text = await page.textContent('.msg');
expect(text).toBe('Saved');
```
### Anti-Patterns
| ❌ Don't | ✅ Do | Why |
|----------|-------|-----|
| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky |
| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry |
| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector |
| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible |
| Shared state between tests | `test.beforeEach` for setup | Tests must be independent |
| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures |
### Test Structure
```typescript
import { test, expect } from '../fixtures/base';
test.describe('Feature Name', () => {
test('should do specific thing', async ({ page }) => {
await test.step('Navigate to page', async () => {
await page.goto('/login');
});
await test.step('Fill form', async () => {
await page.getByLabel('Email').fill('test@example.com');
});
await test.step('Verify result', async () => {
await expect(page).toHaveURL(/\/search/);
});
});
});
```
Use `test.step()` for readability in complex flows.
## Step 4 — Page Object Models
Use POM for any test file with more than 2 tests. Match the app's route structure:
```typescript
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: /sign in|log in/i });
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
```
**Key pages to model (match `app.routes.ts`):**
| Route | Page Object | Component |
|-------|-------------|-----------|
| `/login` | `LoginPage` | `LoginComponent` |
| `/register` | `RegisterPage` | `RegisterComponent` |
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
| `/settings` | `SettingsPage` | `SettingsComponent` |
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
## Step 5 — MetoYou App Architecture Context
The agent writing tests MUST understand these domain boundaries:
### Voice/WebRTC Stack
| Layer | What It Does | Test Relevance |
|-------|-------------|----------------|
| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against |
| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes |
| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation |
| `VoicePlaybackService` | Per-peer GainNode (0200% volume) | Volume level assertions |
| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection |
| `MediaManager` | getUserMedia, mute, gain chain | Track state validation |
| `SignalingManager` | WebSocket per signal URL | Connection establishment |
### Voice UI Components
| Component | Selector | Contains |
|-----------|----------|----------|
| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout |
| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons |
| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls |
| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile |
### Voice UI Icons (Lucide)
| Icon | Meaning |
|------|---------|
| `lucideMic` / `lucideMicOff` | Mute toggle |
| `lucideVideo` / `lucideVideoOff` | Camera toggle |
| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle |
| `lucidePhoneOff` | Hang up / disconnect |
| `lucideHeadphones` | Deafen state |
| `lucideVolume2` / `lucideVolumeX` | Volume indicator |
### Server & Signaling
- **Signaling server**: Port `3001` (HTTP by default, HTTPS if `SSL=true`)
- **Angular dev server**: Port `4200`
- **WebSocket signaling**: Upgrades on same port as server
- **Protocol**: `identify``server_users` → SDP offer/answer → ICE candidates
- **PING/PONG**: Every 30s, 45s timeout
## Step 6 — Validation Workflow
After generating any test:
```
1. npx playwright install --with-deps chromium # First time only
2. npx playwright test --project=chromium # Run tests
3. npx playwright test --ui # Interactive debug
4. npx playwright show-report # HTML report
```
If the test involves WebRTC, always verify:
- Fake media flags are set in config
- Timeouts are sufficient (60s+ for connection establishment)
- `workers: 1` if tests share server state
- Browser permissions granted for microphone/camera
## Quick Reference — Commands
```bash
npx playwright test # Run all
npx playwright test --ui # Interactive UI
npx playwright test --debug # Step-through debugger
npx playwright test tests/voice/ # Voice tests only
npx playwright test --project=chromium # Single browser
npx playwright test -g "voice connects" # By test name
npx playwright show-report # HTML report
npx playwright codegen http://localhost:4200 # Record test
```
## Reference Files
| File | When to Read |
|------|-------------|
| [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) | Voice/video/WebRTC tests, multi-browser contexts, audio validation |
| [reference/project-setup.md](./reference/project-setup.md) | First-time scaffold, dependency installation, config creation |

View File

@@ -0,0 +1,536 @@
# Multi-Client WebRTC Testing
This reference covers the hardest E2E testing scenario in MetoYou: verifying that voice/video connections actually work between multiple clients.
## Core Concept: Multiple Browser Contexts
Playwright can create multiple **independent** browser contexts within a single test. Each context is an isolated session (separate cookies, storage, WebRTC state). This is how we simulate multiple users.
```typescript
import { test, expect, chromium } from '@playwright/test';
test('two users can voice chat', async () => {
const browser = await chromium.launch({
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--use-file-for-fake-audio-capture=e2e/fixtures/test-tone-440hz.wav',
],
});
const contextA = await browser.newContext({
permissions: ['microphone', 'camera'],
});
const contextB = await browser.newContext({
permissions: ['microphone', 'camera'],
});
const alice = await contextA.newPage();
const bob = await contextB.newPage();
// ... test logic with alice and bob ...
await contextA.close();
await contextB.close();
await browser.close();
});
```
## Custom Fixture: Multi-Client
Create a reusable fixture for multi-client tests:
```typescript
// e2e/fixtures/multi-client.ts
import { test as base, chromium, type Page, type BrowserContext, type Browser } from '@playwright/test';
type Client = {
page: Page;
context: BrowserContext;
};
type MultiClientFixture = {
createClient: () => Promise<Client>;
browser: Browser;
};
export const test = base.extend<MultiClientFixture>({
browser: async ({}, use) => {
const browser = await chromium.launch({
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
});
await use(browser);
await browser.close();
},
createClient: async ({ browser }, use) => {
const clients: Client[] = [];
const factory = async (): Promise<Client> => {
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200',
});
const page = await context.newPage();
clients.push({ page, context });
return { page, context };
};
await use(factory);
// Cleanup
for (const client of clients) {
await client.context.close();
}
},
});
export { expect } from '@playwright/test';
```
**Usage:**
```typescript
import { test, expect } from '../fixtures/multi-client';
test('voice call connects between two users', async ({ createClient }) => {
const alice = await createClient();
const bob = await createClient();
// Login both users
await alice.page.goto('/login');
await bob.page.goto('/login');
// ... login flows ...
});
```
## Fake Media Devices
Chromium's fake device flags are essential for headless WebRTC testing:
| Flag | Purpose |
|------|---------|
| `--use-fake-device-for-media-stream` | Provides fake mic/camera devices (no real hardware needed) |
| `--use-fake-ui-for-media-stream` | Auto-grants device permission prompts |
| `--use-file-for-fake-audio-capture=<path>` | Feeds a WAV/WebM file as fake mic input |
| `--use-file-for-fake-video-capture=<path>` | Feeds a Y4M/MJPEG file as fake video input |
### Test Audio File
Create a 440Hz sine wave test tone for audio verification:
```bash
# Generate a 5-second 440Hz mono WAV test tone
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
```
Or use Playwright's built-in fake device which generates a test pattern (beep) automatically when `--use-fake-device-for-media-stream` is set without specifying a file.
## WebRTC Connection Introspection
### Checking RTCPeerConnection State
Inject JavaScript to inspect WebRTC internals via `page.evaluate()`:
```typescript
// e2e/helpers/webrtc-helpers.ts
/**
* Get all RTCPeerConnection instances and their states.
* Requires the app to expose connections (or use a monkey-patch approach).
*/
export async function getPeerConnectionStates(page: Page): Promise<Array<{
connectionState: string;
iceConnectionState: string;
signalingState: string;
}>> {
return page.evaluate(() => {
// Approach 1: Use chrome://webrtc-internals equivalent
// Approach 2: Monkey-patch RTCPeerConnection (install before app loads)
// Approach 3: Expose from app (preferred for this project)
// MetoYou exposes via Angular — access the injector
const appRef = (window as any).ng?.getComponent(document.querySelector('app-root'));
// This needs adaptation based on actual exposure method
// Fallback: Use performance.getEntriesByType or WebRTC stats
return [];
});
}
/**
* Wait for at least one peer connection to reach 'connected' state.
*/
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(() => {
// Check if any RTCPeerConnection reached 'connected'
return (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false;
}, { timeout });
}
/**
* Get WebRTC stats for audio tracks (inbound/outbound).
*/
export async function getAudioStats(page: Page): Promise<{
outbound: { bytesSent: number; packetsSent: number } | null;
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) return { outbound: null, inbound: null };
const pc = connections[0];
const stats = await pc.getStats();
let outbound: any = null;
let inbound: any = null;
stats.forEach((report) => {
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
outbound = { bytesSent: report.bytesSent, packetsSent: report.packetsSent };
}
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
inbound = { bytesReceived: report.bytesReceived, packetsReceived: report.packetsReceived };
}
});
return { outbound, inbound };
});
}
```
### RTCPeerConnection Monkey-Patch
To track all peer connections created by the app, inject a monkey-patch **before** navigation:
```typescript
/**
* Install RTCPeerConnection tracking on a page BEFORE navigating.
* Call this immediately after page creation, before any goto().
*/
export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => {
const connections: RTCPeerConnection[] = [];
(window as any).__rtcConnections = connections;
const OriginalRTCPeerConnection = window.RTCPeerConnection;
(window as any).RTCPeerConnection = function (...args: any[]) {
const pc = new OriginalRTCPeerConnection(...args);
connections.push(pc);
pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState;
});
// Track remote streams
pc.addEventListener('track', (event) => {
if (!((window as any).__rtcRemoteTracks)) {
(window as any).__rtcRemoteTracks = [];
}
(window as any).__rtcRemoteTracks.push({
kind: event.track.kind,
id: event.track.id,
readyState: event.track.readyState,
});
});
return pc;
} as any;
// Preserve prototype chain
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
});
}
```
## Voice Call Test Pattern — Full Example
This is the canonical pattern for testing that voice actually connects between two clients:
```typescript
import { test, expect } from '../fixtures/multi-client';
import { installWebRTCTracking, getAudioStats } from '../helpers/webrtc-helpers';
test.describe('Voice Call', () => {
test('two users connect voice and exchange audio', async ({ createClient }) => {
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking BEFORE navigation
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
await test.step('Both users log in', async () => {
// Login Alice
await alice.page.goto('/login');
await alice.page.getByLabel('Email').fill('alice@test.com');
await alice.page.getByLabel('Password').fill('password123');
await alice.page.getByRole('button', { name: /sign in/i }).click();
await expect(alice.page).toHaveURL(/\/search/);
// Login Bob
await bob.page.goto('/login');
await bob.page.getByLabel('Email').fill('bob@test.com');
await bob.page.getByLabel('Password').fill('password123');
await bob.page.getByRole('button', { name: /sign in/i }).click();
await expect(bob.page).toHaveURL(/\/search/);
});
await test.step('Both users join the same room', async () => {
// Navigate to a shared room (adapt URL to actual room ID)
const roomUrl = '/room/test-room-id';
await alice.page.goto(roomUrl);
await bob.page.goto(roomUrl);
// Verify both are in the room
await expect(alice.page.locator('app-chat-room')).toBeVisible();
await expect(bob.page.locator('app-chat-room')).toBeVisible();
});
await test.step('Alice starts voice', async () => {
// Click the voice/call join button (adapt selector to actual UI)
await alice.page.getByRole('button', { name: /join voice|connect/i }).click();
// Voice workspace should appear
await expect(alice.page.locator('app-voice-workspace')).toBeVisible();
// Voice controls should be visible
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
});
await test.step('Bob joins voice', async () => {
await bob.page.getByRole('button', { name: /join voice|connect/i }).click();
await expect(bob.page.locator('app-voice-workspace')).toBeVisible();
});
await test.step('WebRTC connection establishes', async () => {
// Wait for peer connection to reach 'connected' on both sides
await alice.page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: any) => pc.connectionState === 'connected'
),
{ timeout: 30_000 }
);
await bob.page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: any) => pc.connectionState === 'connected'
),
{ timeout: 30_000 }
);
});
await test.step('Audio is flowing in both directions', async () => {
// Wait a moment for audio stats to accumulate
await alice.page.waitForTimeout(3_000); // Acceptable here: waiting for stats accumulation
// Check Alice is sending audio
const aliceStats = await getAudioStats(alice.page);
expect(aliceStats.outbound).not.toBeNull();
expect(aliceStats.outbound!.bytesSent).toBeGreaterThan(0);
expect(aliceStats.outbound!.packetsSent).toBeGreaterThan(0);
// Check Bob is sending audio
const bobStats = await getAudioStats(bob.page);
expect(bobStats.outbound).not.toBeNull();
expect(bobStats.outbound!.bytesSent).toBeGreaterThan(0);
// Check Alice receives Bob's audio
expect(aliceStats.inbound).not.toBeNull();
expect(aliceStats.inbound!.bytesReceived).toBeGreaterThan(0);
// Check Bob receives Alice's audio
expect(bobStats.inbound).not.toBeNull();
expect(bobStats.inbound!.bytesReceived).toBeGreaterThan(0);
});
await test.step('Voice UI states are correct', async () => {
// Both should see stream tiles for each other
await expect(alice.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
// Mute button should be visible and in unmuted state
// (lucideMic icon visible, lucideMicOff NOT visible)
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
});
await test.step('Mute toggles correctly', async () => {
// Alice mutes
await alice.page.getByRole('button', { name: /mute/i }).click();
// Alice's local UI shows muted state
// Bob should see Alice as muted (mute indicator on her tile)
// Verify no audio being sent from Alice after mute
const preStats = await getAudioStats(alice.page);
await alice.page.waitForTimeout(2_000);
const postStats = await getAudioStats(alice.page);
// Bytes sent should not increase (or increase minimally — comfort noise)
// The exact assertion depends on whether mute stops the track or sends silence
});
await test.step('Alice hangs up', async () => {
await alice.page.getByRole('button', { name: /hang up|disconnect|leave/i }).click();
// Voice workspace should disappear for Alice
await expect(alice.page.locator('app-voice-workspace')).not.toBeVisible();
// Bob should see Alice's tile disappear
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(1);
});
});
});
```
## Verifying Audio Is Actually Received
Beyond checking `bytesReceived > 0`, you can verify actual audio energy:
```typescript
/**
* Check if audio energy is present on a received stream.
* Uses Web Audio AnalyserNode — same approach as MetoYou's VoiceActivityService.
*/
export async function hasAudioEnergy(page: Page): Promise<boolean> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[];
if (!connections?.length) return false;
for (const pc of connections) {
const receivers = pc.getReceivers();
for (const receiver of receivers) {
if (receiver.track.kind !== 'audio' || receiver.track.readyState !== 'live') continue;
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(new MediaStream([receiver.track]));
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
// Sample over 500ms
const dataArray = new Float32Array(analyser.frequencyBinCount);
await new Promise(resolve => setTimeout(resolve, 500));
analyser.getFloatTimeDomainData(dataArray);
// Calculate RMS (same as VoiceActivityService)
let sum = 0;
for (const sample of dataArray) {
sum += sample * sample;
}
const rms = Math.sqrt(sum / dataArray.length);
audioCtx.close();
// MetoYou uses threshold 0.015 — use lower threshold for test
if (rms > 0.005) return true;
}
}
return false;
});
}
```
## Testing Speaker/Playback Volume
MetoYou uses per-peer GainNode chains (0200%). To verify:
```typescript
await test.step('Bob adjusts Alice volume to 50%', async () => {
// Interact with volume slider in stream tile
// (adapt selector to actual volume control UI)
const volumeSlider = bob.page.locator('app-voice-workspace-stream-tile')
.filter({ hasText: 'Alice' })
.getByRole('slider');
await volumeSlider.fill('50');
// Verify the gain was set (check via WebRTC or app state)
const gain = await bob.page.evaluate(() => {
// Access VoicePlaybackService through Angular DI if exposed
// Or check audio element volume
const audioElements = document.querySelectorAll('audio');
return audioElements[0]?.volume;
});
});
```
## Testing Screen Share
Screen share requires `getDisplayMedia()` which cannot be auto-granted. Options:
1. **Mock at the browser level** — use `page.addInitScript()` to replace `getDisplayMedia` with a fake stream
2. **Use Chromium flags**`--auto-select-desktop-capture-source=Entire screen`
```typescript
// Mock getDisplayMedia before navigation
await page.addInitScript(() => {
navigator.mediaDevices.getDisplayMedia = async () => {
// Create a simple canvas stream as fake screen share
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#4a90d9';
ctx.fillRect(0, 0, 1280, 720);
ctx.fillStyle = 'white';
ctx.font = '48px sans-serif';
ctx.fillText('Fake Screen Share', 400, 380);
return canvas.captureStream(30);
};
});
```
## Debugging Tips
### Trace Viewer
When a WebRTC test fails, the trace captures network requests, console logs, and screenshots:
```bash
npx playwright show-trace test-results/trace.zip
```
### Console Log Forwarding
Forward browser console to Node for real-time debugging:
```typescript
page.on('console', msg => console.log(`[${name}]`, msg.text()));
page.on('pageerror', err => console.error(`[${name}] PAGE ERROR:`, err.message));
```
### WebRTC Internals
Chromium exposes WebRTC stats at `chrome://webrtc-internals/`. In Playwright, access the same data via:
```typescript
const stats = await page.evaluate(async () => {
const pcs = (window as any).__rtcConnections;
return Promise.all(pcs.map(async (pc: any) => {
const stats = await pc.getStats();
const result: any[] = [];
stats.forEach((report: any) => result.push(report));
return result;
}));
});
```
## Timeout Guidelines
| Operation | Recommended Timeout |
|-----------|-------------------|
| Page navigation | 10s (default) |
| Login flow | 15s |
| WebRTC connection establishment | 30s |
| ICE negotiation (TURN fallback) | 45s |
| Audio stats accumulation | 35s after connection |
| Full voice test (end-to-end) | 90s |
| Screen share setup | 15s |
## Parallelism Warning
WebRTC multi-client tests **must** run with `workers: 1` (sequential) because:
- All clients share the same signaling server instance
- Server state (rooms, users) is mutable
- ICE candidates reference `localhost` — port conflicts possible with parallel launches
If you need parallelism, use separate server instances with different ports per worker.

View File

@@ -0,0 +1,175 @@
# Project Setup — Playwright E2E for MetoYou
Before creating any tests or changing them read AGENTS.md for a understanding how the application works.
## First-Time Scaffold
### 1. Install Dependencies
From the **repository root**:
```bash
npm install -D @playwright/test
npx playwright install --with-deps chromium
```
Only Chromium is needed — WebRTC fake media flags are Chromium-only. Add Firefox/WebKit later if needed for non-WebRTC tests.
### 2. Create Directory Structure
```bash
mkdir -p e2e/{tests/{auth,chat,voice,settings},fixtures,pages,helpers}
```
### 3. Create Config
Create `e2e/playwright.config.ts`:
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
outputDir: '../test-results/artifacts',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
permissions: ['microphone', 'camera'],
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
},
},
},
],
webServer: [
{
command: 'cd ../server && npm run dev',
port: 3001,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
{
command: 'cd ../toju-app && npx ng serve',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
],
});
```
### 4. Create Base Fixture
Create `e2e/fixtures/base.ts`:
```typescript
import { test as base } from '@playwright/test';
export const test = base.extend({
// Add common fixtures here as the test suite grows
// Examples: authenticated page, test data seeding, etc.
});
export { expect } from '@playwright/test';
```
### 5. Create Multi-Client Fixture
Create `e2e/fixtures/multi-client.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for the full fixture code.
### 6. Create WebRTC Helpers
Create `e2e/helpers/webrtc-helpers.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for helper functions.
### 7. Create Isolated Test Server Launcher
The app requires a signal server. Tests use an isolated instance with its own temporary database so test data never pollutes the dev environment.
Create `e2e/helpers/start-test-server.js` — a Node.js script that:
1. Creates a temp directory under the OS tmpdir
2. Writes a `data/variables.json` with `serverPort: 3099`, `serverProtocol: "http"`
3. Spawns `ts-node server/src/index.ts` with `cwd` set to the temp dir
4. Cleans up the temp dir on exit
The server's `getRuntimeBaseDir()` returns `process.cwd()`, so setting cwd to the temp dir makes the database go to `<tmpdir>/data/metoyou.sqlite`. Module resolution (`require`/`import`) uses `__dirname`, so server source and `node_modules` resolve correctly from the real `server/` directory.
Playwright's `webServer` config calls this script and waits for port 3099 to be ready.
### 8. Create Test Endpoint Seeder
The Angular app reads signal endpoints from `localStorage['metoyou_server_endpoints']`. By default it falls back to production URLs in `environment.ts`. For tests, seed localStorage with a single endpoint pointing at `http://localhost:3099`.
Create `e2e/helpers/seed-test-endpoint.ts` — called automatically by the multi-client fixture after creating each browser context. The flow is:
1. Navigate to `/` (establishes the origin for localStorage)
2. Set `metoyou_server_endpoints` to `[{ id: 'e2e-test-server', url: 'http://localhost:3099', ... }]`
3. Set `metoyou_removed_default_server_keys` to suppress production endpoints
4. Reload the page so the app picks up the test endpoint
### 9. Add npm Scripts
Add to root `package.json`:
```json
{
"scripts": {
"test:e2e": "cd e2e && npx playwright test",
"test:e2e:ui": "cd e2e && npx playwright test --ui",
"test:e2e:debug": "cd e2e && npx playwright test --debug",
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
}
}
```
### 8. Update .gitignore
Add to `.gitignore`:
```
# Playwright
test-results/
e2e/playwright-report/
```
### 9. Generate Test Audio Fixture (Optional)
For voice tests with controlled audio input:
```bash
# Requires ffmpeg
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
```
## Existing Dev Stack Integration
The tests assume the standard MetoYou dev stack:
- **Signaling server** at `http://localhost:3001` (via `server/npm run dev`)
- **Angular dev server** at `http://localhost:4200` (via `toju-app/npx ng serve`)
The `webServer` config in `playwright.config.ts` starts these automatically if not already running. When running `npm run dev` (full Electron stack) separately, tests will reuse the existing servers.
## Test Data Requirements
E2E tests need user accounts to log in with. Options:
1. **Seed via API** — create users in `test.beforeAll` via the server REST API
2. **Pre-seeded database** — maintain a test SQLite database with known accounts
3. **Register in test** — use the `/register` flow as a setup step (slower but self-contained)
Recommended: Option 3 for initial setup, migrate to Option 1 as the test suite grows.

4
.gitignore vendored
View File

@@ -44,6 +44,10 @@ testem.log
/typings /typings
__screenshots__/ __screenshots__/
# Playwright
test-results/
e2e/playwright-report/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db

4
e2e/fixtures/base.ts Normal file
View File

@@ -0,0 +1,4 @@
import { test as base } from '@playwright/test';
export const test = base;
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,57 @@
import {
test as base,
chromium,
type Page,
type BrowserContext,
type Browser
} from '@playwright/test';
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
export type Client = {
page: Page;
context: BrowserContext;
};
type MultiClientFixture = {
createClient: () => Promise<Client>;
browser: Browser;
};
export const test = base.extend<MultiClientFixture>({
browser: async ({}, use) => {
const browser = await chromium.launch({
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
});
await use(browser);
await browser.close();
},
createClient: async ({ browser }, use) => {
const clients: Client[] = [];
const factory = async (): Promise<Client> => {
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200'
});
await installTestServerEndpoint(context);
const page = await context.newPage();
clients.push({ page, context });
return { page, context };
};
await use(factory);
for (const client of clients) {
await client.context.close();
}
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,77 @@
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';
type SeededEndpointStorageState = {
key: string;
removedKey: string;
endpoints: {
id: string;
name: string;
url: string;
isActive: boolean;
isDefault: boolean;
status: string;
}[];
};
function buildSeededEndpointStorageState(
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): SeededEndpointStorageState {
const endpoint = {
id: 'e2e-test-server',
name: 'E2E Test Server',
url: `http://localhost:${port}`,
isActive: true,
isDefault: false,
status: 'unknown'
};
return {
key: SERVER_ENDPOINTS_STORAGE_KEY,
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
endpoints: [endpoint]
};
}
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
try {
const storage = window.localStorage;
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden']));
} catch {
// about:blank and some Playwright UI pages deny localStorage access.
}
}
export async function installTestServerEndpoint(
context: BrowserContext,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): Promise<void> {
const storageState = buildSeededEndpointStorageState(port);
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)
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
* relied upon — calling it in the first goto() landing page is fine since the
* page will re-read on next navigation/reload).
*
* Typical usage:
* await page.goto('/');
* await seedTestServerEndpoint(page);
* await page.reload(); // App now picks up the test endpoint
*/
export async function seedTestServerEndpoint(
page: Page,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): Promise<void> {
const storageState = buildSeededEndpointStorageState(port);
await page.evaluate(applySeededEndpointStorageState, storageState);
}

View File

@@ -0,0 +1,95 @@
/**
* Launches an isolated MetoYou signaling server for E2E tests.
*
* Creates a temporary data directory so the test server gets its own
* fresh SQLite database. The server process inherits stdio so Playwright
* can watch stdout for readiness and the developer can see logs.
*
* Cleanup: the temp directory is removed when the process exits.
*/
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
const { join } = require('path');
const { tmpdir } = require('os');
const { spawn } = require('child_process');
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
const SERVER_DIR = join(__dirname, '..', '..', 'server');
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
// ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
const dataDir = join(tmpDir, 'data');
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(dataDir, 'variables.json'),
JSON.stringify({
serverPort: parseInt(TEST_PORT, 10),
serverProtocol: 'http',
serverHost: '',
klipyApiKey: '',
releaseManifestUrl: '',
linkPreview: { enabled: false, cacheTtlMinutes: 60, maxCacheSizeMb: 10 },
})
);
console.log(`[E2E Server] Temp data dir: ${tmpDir}`);
console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
// ── Spawn the server with cwd = temp dir ─────────────────────────────
// process.cwd() is used by getRuntimeBaseDir() in the server, so data/
// (database, variables.json) will resolve to our temp directory.
// Module resolution (require/import) uses __dirname, so server source
// and node_modules are found from the real server/ directory.
const child = spawn(
'npx',
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{
cwd: tmpDir,
env: {
...process.env,
PORT: TEST_PORT,
SSL: 'false',
NODE_ENV: 'test',
DB_SYNCHRONIZE: 'true',
},
stdio: 'inherit',
shell: true,
}
);
child.on('error', (err) => {
console.error('[E2E Server] Failed to start:', err.message);
cleanup();
process.exit(1);
});
child.on('exit', (code) => {
console.log(`[E2E Server] Exited with code ${code}`);
cleanup();
});
// ── Cleanup on signals ───────────────────────────────────────────────
function cleanup() {
try {
rmSync(tmpDir, { recursive: true, force: true });
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
} catch {
// already gone
}
}
function shutdown() {
child.kill('SIGTERM');
// Give child 3s to exit, then force kill
setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
cleanup();
process.exit(0);
}, 3_000);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('exit', cleanup);

View File

@@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type Page } from '@playwright/test';
/**
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
* Tracks all created peer connections and their remote tracks so tests
* can inspect WebRTC state via `page.evaluate()`.
*
* Call immediately after page creation, before any `goto()`.
*/
export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => {
const connections: RTCPeerConnection[] = [];
(window as any).__rtcConnections = connections;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
const OriginalRTCPeerConnection = window.RTCPeerConnection;
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
connections.push(pc);
pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState;
});
pc.addEventListener('track', (event: RTCTrackEvent) => {
(window as any).__rtcRemoteTracks.push({
kind: event.track.kind,
id: event.track.id,
readyState: event.track.readyState
});
});
return pc;
} as any;
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
});
}
/**
* Wait until at least one RTCPeerConnection reaches the 'connected' state.
*/
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false,
{ timeout }
);
}
/**
* Check that a peer connection is still in 'connected' state (not failed/disconnected).
*/
export async function isPeerStillConnected(page: Page): Promise<boolean> {
return page.evaluate(
() => (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false
);
}
/**
* Get outbound and inbound audio RTP stats from the first peer connection.
*/
export async function getAudioStats(page: Page): Promise<{
outbound: { bytesSent: number; packetsSent: number } | null;
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return { outbound: null, inbound: null };
let outbound: { bytesSent: number; packetsSent: number } | null = null;
let inbound: { bytesReceived: number; packetsReceived: number } | null = null;
for (const pc of connections) {
if (pc.connectionState !== 'connected')
continue;
const stats = await pc.getStats();
stats.forEach((report: any) => {
const reportMediaType = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && reportMediaType === 'audio' && !outbound) {
outbound = {
bytesSent: report.bytesSent ?? 0,
packetsSent: report.packetsSent ?? 0
};
}
if (report.type === 'inbound-rtp' && reportMediaType === 'audio' && !inbound) {
inbound = {
bytesReceived: report.bytesReceived ?? 0,
packetsReceived: report.packetsReceived ?? 0
};
}
});
if (outbound && inbound)
break;
}
return { outbound, inbound };
});
}
/**
* Snapshot audio stats, wait `durationMs`, snapshot again, and return the delta.
* Useful for verifying audio is actively flowing (bytes increasing).
*/
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
outboundBytesDelta: number;
inboundBytesDelta: number;
}> {
const before = await getAudioStats(page);
await page.waitForTimeout(durationMs);
const after = await getAudioStats(page);
return {
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0)
};
}

View File

@@ -0,0 +1,79 @@
import {
expect,
type Page,
type Locator
} from '@playwright/test';
export class ChatRoomPage {
readonly chatMessages: Locator;
readonly voiceWorkspace: Locator;
readonly channelsSidePanel: Locator;
readonly usersSidePanel: Locator;
constructor(private page: Page) {
this.chatMessages = page.locator('app-chat-messages');
this.voiceWorkspace = page.locator('app-voice-workspace');
this.channelsSidePanel = page.locator('app-rooms-side-panel').first();
this.usersSidePanel = page.locator('app-rooms-side-panel').last();
}
/** 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 });
await expect(channelButton).toBeVisible({ timeout: 15_000 });
await channelButton.click();
}
/** Click "Create Voice Channel" button in the channels sidebar. */
async openCreateVoiceChannelDialog() {
await this.page.locator('button[title="Create Voice Channel"]').click();
}
/** Click "Create Text Channel" button in the channels sidebar. */
async openCreateTextChannelDialog() {
await this.page.locator('button[title="Create Text Channel"]').click();
}
/** Fill the channel name in the create channel dialog and confirm. */
async createChannel(name: string) {
const dialog = this.page.locator('app-confirm-dialog');
const channelNameInput = dialog.getByPlaceholder('Channel name');
const createButton = dialog.getByRole('button', { name: 'Create', exact: true });
await expect(channelNameInput).toBeVisible({ timeout: 10_000 });
await channelNameInput.fill(name);
await createButton.click();
}
/** Get the voice controls component. */
get voiceControls() {
return this.page.locator('app-voice-controls');
}
/** Get the mute toggle button inside voice controls. */
get muteButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first();
}
/** Get the disconnect/hang-up button (destructive styled). */
get disconnectButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
}
/** Get all voice stream tiles. */
get streamTiles() {
return this.page.locator('app-voice-workspace-stream-tile');
}
/** 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');
return userAvatars.count();
}
}

29
e2e/pages/login.page.ts Normal file
View File

@@ -0,0 +1,29 @@
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly serverSelect: Locator;
readonly submitButton: Locator;
readonly errorText: Locator;
readonly registerLink: Locator;
constructor(private page: Page) {
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.errorText = page.locator('.text-destructive');
this.registerLink = page.getByRole('button', { name: 'Register' });
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,35 @@
import { expect, type Page, type Locator } from '@playwright/test';
export class RegisterPage {
readonly usernameInput: Locator;
readonly displayNameInput: Locator;
readonly passwordInput: Locator;
readonly serverSelect: Locator;
readonly submitButton: Locator;
readonly errorText: Locator;
readonly loginLink: Locator;
constructor(private page: Page) {
this.usernameInput = page.locator('#register-username');
this.displayNameInput = page.locator('#register-display-name');
this.passwordInput = page.locator('#register-password');
this.serverSelect = page.locator('#register-server');
this.submitButton = page.getByRole('button', { name: 'Create Account' });
this.errorText = page.locator('.text-destructive');
this.loginLink = page.getByRole('button', { name: 'Login' });
}
async goto() {
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
}
async register(username: string, displayName: string, password: string) {
await this.usernameInput.fill(username);
await this.displayNameInput.fill(displayName);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,65 @@
import {
type Page,
type Locator,
expect
} from '@playwright/test';
export class ServerSearchPage {
readonly searchInput: Locator;
readonly createServerButton: Locator;
readonly settingsButton: Locator;
// Create server dialog
readonly serverNameInput: Locator;
readonly serverDescriptionInput: Locator;
readonly serverTopicInput: Locator;
readonly signalEndpointSelect: Locator;
readonly privateCheckbox: Locator;
readonly serverPasswordInput: Locator;
readonly dialogCreateButton: Locator;
readonly dialogCancelButton: Locator;
constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers...');
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
this.settingsButton = page.locator('button[title="Settings"]');
// Create dialog elements
this.serverNameInput = page.locator('#create-server-name');
this.serverDescriptionInput = page.locator('#create-server-description');
this.serverTopicInput = page.locator('#create-server-topic');
this.signalEndpointSelect = page.locator('#create-server-signal-endpoint');
this.privateCheckbox = page.locator('#private');
this.serverPasswordInput = page.locator('#create-server-password');
this.dialogCreateButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Create' });
this.dialogCancelButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Cancel' });
}
async goto() {
await this.page.goto('/search');
}
async createServer(name: string, options?: { description?: string; topic?: string }) {
await this.createServerButton.click();
await expect(this.serverNameInput).toBeVisible();
await this.serverNameInput.fill(name);
if (options?.description) {
await this.serverDescriptionInput.fill(options.description);
}
if (options?.topic) {
await this.serverTopicInput.fill(options.topic);
}
await this.dialogCreateButton.click();
}
async joinSavedRoom(name: string) {
await this.page.getByRole('button', { name }).click();
}
async joinServerFromSearch(name: string) {
await this.page.locator('button', { hasText: name }).click();
}
}

52
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,52 @@
import { defineConfig, devices } from '@playwright/test';
const TEST_SERVER_PORT = Number(process.env.TEST_SERVER_PORT) || 3099;
export default defineConfig({
testDir: './tests',
timeout: 90_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
outputDir: '../test-results/artifacts',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
actionTimeout: 15_000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ['microphone', 'camera'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
},
},
},
],
webServer: [
{
// Isolated test server with its own temporary database.
// See e2e/helpers/start-test-server.js for details.
command: `node helpers/start-test-server.js`,
port: TEST_SERVER_PORT,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
env: { TEST_SERVER_PORT: String(TEST_SERVER_PORT) },
},
{
command: 'cd ../toju-app && npx ng serve',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
});

View File

@@ -0,0 +1,238 @@
import { test, expect } from '../../fixtures/multi-client';
import {
installWebRTCTracking,
waitForPeerConnected,
isPeerStillConnected,
getAudioStatsDelta
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
/**
* 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
* multi-user WebRTC voice chat.
*/
const ALICE = { username: `alice_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' };
const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' };
const SERVER_NAME = `E2E Test Server ${Date.now()}`;
const VOICE_CHANNEL = 'General';
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
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking before any navigation
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
// Forward browser console for debugging
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
// ── Step 1: Register both users ──────────────────────────────────
await test.step('Alice registers an account', async () => {
const registerPage = new RegisterPage(alice.page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(ALICE.username, ALICE.displayName, ALICE.password);
// After registration, app should navigate to /search
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
});
await test.step('Bob registers an account', async () => {
const registerPage = new RegisterPage(bob.page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(BOB.username, BOB.displayName, BOB.password);
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
});
// ── Step 2: Alice creates a server ───────────────────────────────
await test.step('Alice creates a new server', async () => {
const searchPage = new ServerSearchPage(alice.page);
await searchPage.createServer(SERVER_NAME, {
description: 'E2E test server for voice testing'
});
// After server creation, app navigates to the room
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
// ── Step 3: Bob joins the server ─────────────────────────────────
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();
// Bob should be in the room now
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
// ── Step 4: Create a voice channel (if one doesn't exist) ────────
await test.step('Alice ensures a voice channel is available', async () => {
const chatRoom = new ChatRoomPage(alice.page);
const existingVoiceChannel = alice.page.locator('app-rooms-side-panel')
.getByRole('button', { name: VOICE_CHANNEL, exact: true });
const voiceChannelExists = await existingVoiceChannel.count() > 0;
if (!voiceChannelExists) {
// Click "Create Voice Channel" plus button
await chatRoom.openCreateVoiceChannelDialog();
await chatRoom.createChannel(VOICE_CHANNEL);
// Wait for the channel to appear
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}
});
// ── Step 5: Both users join the voice channel ────────────────────
await test.step('Alice joins the voice channel', async () => {
const chatRoom = new ChatRoomPage(alice.page);
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
// Voice controls should appear (indicates voice is connected)
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
});
await test.step('Bob joins the voice channel', async () => {
const chatRoom = new ChatRoomPage(bob.page);
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
});
// ── Step 6: Verify WebRTC connection establishes ─────────────────
await test.step('WebRTC peer connection reaches "connected" state', async () => {
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
});
// ── Step 7: Verify audio is flowing in both directions ───────────
await test.step('Audio packets are flowing between Alice and Bob', async () => {
// Wait a moment for audio pipeline to stabilize
const aliceDelta = await getAudioStatsDelta(alice.page, 3_000);
expect(aliceDelta.outboundBytesDelta).toBeGreaterThan(0);
expect(aliceDelta.inboundBytesDelta).toBeGreaterThan(0);
const bobDelta = await getAudioStatsDelta(bob.page, 3_000);
expect(bobDelta.outboundBytesDelta).toBeGreaterThan(0);
expect(bobDelta.inboundBytesDelta).toBeGreaterThan(0);
});
// ── Step 8: Verify UI states are correct ─────────────────────────
await test.step('Voice UI shows correct state for both users', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
// Both should see voice controls with "Connected" status
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
await expect(bob.page.locator('app-voice-controls')).toBeVisible();
// Both should see the voice workspace or at least voice users listed
// Check that both users appear in the voice channel user list
const aliceSeesBob = aliceRoom.channelsSidePanel.getByText(BOB.displayName).first();
const bobSeesAlice = bobRoom.channelsSidePanel.getByText(ALICE.displayName).first();
await expect(aliceSeesBob).toBeVisible({ timeout: 10_000 });
await expect(bobSeesAlice).toBeVisible({ timeout: 10_000 });
});
// ── Step 9: Stay connected for 10+ seconds, verify stability ─────
await test.step('Connection remains stable for 10+ seconds', async () => {
// Check connectivity at 0s, 5s, and 10s intervals
for (const checkpoint of [
0,
5_000,
5_000
]) {
if (checkpoint > 0) {
await alice.page.waitForTimeout(checkpoint);
}
const aliceConnected = await isPeerStillConnected(alice.page);
const bobConnected = await isPeerStillConnected(bob.page);
expect(aliceConnected, 'Alice should still be connected').toBe(true);
expect(bobConnected, 'Bob should still be connected').toBe(true);
}
// After 10s total, verify audio is still flowing
const aliceDelta = await getAudioStatsDelta(alice.page, 2_000);
expect(aliceDelta.outboundBytesDelta, 'Alice should still be sending audio after 10s').toBeGreaterThan(0);
expect(aliceDelta.inboundBytesDelta, 'Alice should still be receiving audio after 10s').toBeGreaterThan(0);
const bobDelta = await getAudioStatsDelta(bob.page, 2_000);
expect(bobDelta.outboundBytesDelta, 'Bob should still be sending audio after 10s').toBeGreaterThan(0);
expect(bobDelta.inboundBytesDelta, 'Bob should still be receiving audio after 10s').toBeGreaterThan(0);
});
// ── Step 10: Verify mute/unmute works correctly ──────────────────
await test.step('Mute toggle works correctly', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
// Alice mutes - click the first button in voice controls (mute button)
await aliceRoom.muteButton.click();
// After muting, Alice's outbound audio should stop increasing
// When muted, bytesSent may still show small comfort noise or zero growth
// The key assertion is that Bob's inbound for Alice's stream stops or reduces
await getAudioStatsDelta(alice.page, 2_000);
// Alice unmutes
await aliceRoom.muteButton.click();
// After unmuting, outbound should resume
const unmutedDelta = await getAudioStatsDelta(alice.page, 2_000);
expect(unmutedDelta.outboundBytesDelta, 'Audio should flow after unmuting').toBeGreaterThan(0);
});
// ── Step 11: Clean disconnect ────────────────────────────────────
await test.step('Alice disconnects from voice', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
// Click the disconnect/hang-up button
await aliceRoom.disconnectButton.click();
// Connected controls should collapse for Alice after disconnect
await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 });
});
});
});

View File

@@ -535,4 +535,26 @@ export function setupSystemHandlers(): void {
request.end(); request.end();
}); });
}); });
ipcMain.handle('context-menu-command', (_event, command: string) => {
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const;
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
return;
}
const mainWindow = getMainWindow();
const webContents = mainWindow?.webContents;
if (!webContents) {
return;
}
switch (command) {
case 'cut': webContents.cut(); break;
case 'copy': webContents.copy(); break;
case 'paste': webContents.paste(); break;
case 'selectAll': webContents.selectAll(); break;
}
});
} }

View File

@@ -211,6 +211,7 @@ export interface ElectronAPI {
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void; onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
contextMenuCommand: (command: string) => Promise<void>;
copyImageToClipboard: (srcURL: string) => Promise<boolean>; copyImageToClipboard: (srcURL: string) => Promise<boolean>;
command: <T = unknown>(command: Command) => Promise<T>; command: <T = unknown>(command: Command) => Promise<T>;
@@ -329,6 +330,7 @@ const electronAPI: ElectronAPI = {
ipcRenderer.removeListener('show-context-menu', wrappedListener); ipcRenderer.removeListener('show-context-menu', wrappedListener);
}; };
}, },
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL), copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
command: (command) => ipcRenderer.invoke('cqrs:command', command), command: (command) => ipcRenderer.invoke('cqrs:command', command),

64
package-lock.json generated
View File

@@ -56,6 +56,7 @@
"@angular/cli": "^21.0.4", "@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@playwright/test": "^1.59.1",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",
@@ -9337,6 +9338,22 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.47", "version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
@@ -24652,6 +24669,53 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": { "node_modules/plist": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",

View File

@@ -49,7 +49,11 @@
"release:version": "node tools/resolve-release-version.js", "release:version": "node tools/resolve-release-version.js",
"server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64", "server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64",
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe", "server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
"sort:props": "node tools/sort-template-properties.js" "sort:props": "node tools/sort-template-properties.js",
"test:e2e": "cd e2e && npx playwright test",
"test:e2e:ui": "cd e2e && npx playwright test --ui",
"test:e2e:debug": "cd e2e && npx playwright test --debug",
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
}, },
"private": true, "private": true,
"packageManager": "npm@10.9.2", "packageManager": "npm@10.9.2",
@@ -102,6 +106,7 @@
"@angular/cli": "^21.0.4", "@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@playwright/test": "^1.59.1",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",

Binary file not shown.

View File

@@ -70,7 +70,7 @@ export async function initDatabase(): Promise<void> {
ServerBanEntity ServerBanEntity
], ],
migrations: serverMigrations, migrations: serverMigrations,
synchronize: false, synchronize: process.env.DB_SYNCHRONIZE === 'true',
logging: false, logging: false,
autoSave: true, autoSave: true,
location: DB_FILE, location: DB_FILE,
@@ -90,8 +90,12 @@ export async function initDatabase(): Promise<void> {
console.log('[DB] Connection initialised at:', DB_FILE); console.log('[DB] Connection initialised at:', DB_FILE);
await applicationDataSource.runMigrations(); if (process.env.DB_SYNCHRONIZE !== 'true') {
console.log('[DB] Migrations executed'); await applicationDataSource.runMigrations();
console.log('[DB] Migrations executed');
} else {
console.log('[DB] Synchronize mode — migrations skipped');
}
} }
export async function destroyDatabase(): Promise<void> { export async function destroyDatabase(): Promise<void> {

View File

@@ -1,10 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import { randomUUID } from 'crypto';
import { getAllPublicServers } from '../cqrs'; import { getAllPublicServers } from '../cqrs';
import { getReleaseManifestUrl } from '../config/variables'; import { getReleaseManifestUrl } from '../config/variables';
import { SERVER_BUILD_VERSION } from '../generated/build-version'; import { SERVER_BUILD_VERSION } from '../generated/build-version';
import { connectedUsers } from '../websocket/state'; import { connectedUsers } from '../websocket/state';
const router = Router(); const router = Router();
const SERVER_INSTANCE_ID = typeof process.env.METOYOU_SERVER_INSTANCE_ID === 'string'
&& process.env.METOYOU_SERVER_INSTANCE_ID.trim().length > 0
? process.env.METOYOU_SERVER_INSTANCE_ID.trim()
: randomUUID();
function getServerProjectVersion(): string { function getServerProjectVersion(): string {
return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0 return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0
@@ -20,6 +25,7 @@ router.get('/health', async (_req, res) => {
timestamp: Date.now(), timestamp: Date.now(),
serverCount: servers.length, serverCount: servers.length,
connectedUsers: connectedUsers.size, connectedUsers: connectedUsers.size,
serverInstanceId: SERVER_INSTANCE_ID,
serverVersion: getServerProjectVersion(), serverVersion: getServerProjectVersion(),
releaseManifestUrl: getReleaseManifestUrl() releaseManifestUrl: getReleaseManifestUrl()
}); });

View File

@@ -10,8 +10,14 @@ interface WsMessage {
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void { export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
// Deduplicate by oderId so users with multiple connections (e.g. from
// different signal URLs routing to the same server) receive the
// broadcast only once.
const sentToOderIds = new Set<string>();
connectedUsers.forEach((user) => { connectedUsers.forEach((user) => {
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) { if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
sentToOderIds.add(user.oderId);
console.log(` -> Sending to ${user.displayName} (${user.oderId})`); console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
user.ws.send(JSON.stringify(message)); user.ws.send(JSON.stringify(message));
} }

View File

@@ -44,12 +44,18 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const newOderId = readMessageId(message['oderId']) ?? connectionId; const newOderId = readMessageId(message['oderId']) ?? connectionId;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
// Close stale connections from the same identity so offer routing // Close stale connections from the same identity AND the same connection
// always targets the freshest socket (e.g. after page refresh). // 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) => { connectedUsers.forEach((existing, existingId) => {
if (existingId !== connectionId && existing.oderId === newOderId) { if (existingId !== connectionId
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId})`); && existing.oderId === newOderId
&& existing.connectionScope === newScope) {
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
try { try {
existing.ws.close(); existing.ws.close();
@@ -61,6 +67,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
user.oderId = newOderId; user.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
user.connectionScope = newScope;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
} }

View File

@@ -6,6 +6,13 @@ export interface ConnectedUser {
serverIds: Set<string>; serverIds: Set<string>;
viewedServerId?: string; viewedServerId?: string;
displayName?: string; displayName?: string;
/**
* Opaque scope string sent by the client (typically the signal URL it
* connected through). Stale-connection eviction only targets connections
* that share the same (oderId, connectionScope) pair, so multiple signal
* URLs routing to the same server coexist without an eviction loop.
*/
connectionScope?: string;
/** Timestamp of the last pong received (used to detect dead connections). */ /** Timestamp of the last pong received (used to detect dead connections). */
lastPong: number; lastPong: number;
} }

10
skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"caveman": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"computedHash": "4d486dd6f9fbb27ce1c51c972c9a5eb25a53236ae05eabf4d076ac1e293f4b7a"
}
}
}

View File

@@ -19,6 +19,8 @@ import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UsersEffects } from './store/users/users.effects'; import { UsersEffects } from './store/users/users.effects';
import { RoomsEffects } from './store/rooms/rooms.effects'; import { RoomsEffects } from './store/rooms/rooms.effects';
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'; import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
import { RoomStateSyncEffects } from './store/rooms/room-state-sync.effects';
import { RoomSettingsEffects } from './store/rooms/room-settings.effects';
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants'; import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */ /** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
@@ -38,7 +40,9 @@ export const appConfig: ApplicationConfig = {
MessagesSyncEffects, MessagesSyncEffects,
UsersEffects, UsersEffects,
RoomsEffects, RoomsEffects,
RoomMembersSyncEffects RoomMembersSyncEffects,
RoomStateSyncEffects,
RoomSettingsEffects
]), ]),
provideStoreDevtools({ provideStoreDevtools({
maxAge: STORE_DEVTOOLS_MAX_AGE, maxAge: STORE_DEVTOOLS_MAX_AGE,

View File

@@ -10,12 +10,12 @@ export const routes: Routes = [
{ {
path: 'login', path: 'login',
loadComponent: () => loadComponent: () =>
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent) import('./domains/authentication/feature/login/login.component').then((module) => module.LoginComponent)
}, },
{ {
path: 'register', path: 'register',
loadComponent: () => loadComponent: () =>
import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent) import('./domains/authentication/feature/register/register.component').then((module) => module.RegisterComponent)
}, },
{ {
path: 'invite/:inviteId', path: 'invite/:inviteId',

View File

@@ -193,6 +193,7 @@ export interface ElectronApi {
deleteFile: (filePath: string) => Promise<boolean>; deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void; onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
contextMenuCommand: (command: string) => Promise<void>;
copyImageToClipboard: (srcURL: string) => Promise<boolean>; copyImageToClipboard: (srcURL: string) => Promise<boolean>;
command: <T = unknown>(command: ElectronCommand) => Promise<T>; command: <T = unknown>(command: ElectronCommand) => Promise<T>;
query: <T = unknown>(query: ElectronQuery) => Promise<T>; query: <T = unknown>(query: ElectronQuery) => Promise<T>;

View File

@@ -9,8 +9,8 @@ infrastructure adapters and UI.
| Domain | Purpose | Public entry point | | Domain | Purpose | Public entry point |
| -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` | | **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` | | **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
@@ -25,7 +25,7 @@ The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md) - [attachment/README.md](attachment/README.md)
- [access-control/README.md](access-control/README.md) - [access-control/README.md](access-control/README.md)
- [auth/README.md](auth/README.md) - [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md) - [notifications/README.md](notifications/README.md)
- [screen-share/README.md](screen-share/README.md) - [screen-share/README.md](screen-share/README.md)

View File

@@ -7,13 +7,18 @@ Role and permission rules for servers, including default system roles, role assi
``` ```
access-control/ access-control/
├── domain/ ├── domain/
│ ├── access-control.models.ts MemberIdentity and RoomPermissionDefinition domain types │ ├── models/
── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata │ └── access-control.model.ts MemberIdentity and RoomPermissionDefinition domain types
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers │ ├── constants/
├── role-assignment.rules.ts Assignment normalization and member-role lookups │ └── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks │ ├── util/
├── room.rules.ts Legacy compatibility, room hydration, room-level normalization │ └── access-control.util.ts Internal helpers (normalization, identity matching, sorting)
│ └── access-control.logic.ts Public barrel for domain rules │ └── rules/
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers
│ ├── role-assignment.rules.ts Assignment normalization and member-role lookups
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks
│ ├── room.rules.ts Legacy compatibility, room hydration, room-level normalization
│ └── ban.rules.ts Ban matching and user-ban resolution
└── index.ts Domain barrel used by other layers └── index.ts Domain barrel used by other layers
``` ```
@@ -29,6 +34,8 @@ access-control/
| `canManageMember(...)` | Applies both permission checks and role hierarchy checks | | `canManageMember(...)` | Applies both permission checks and role hierarchy checks |
| `canManageRole(...)` | Prevents editing roles at or above the actor's highest role | | `canManageRole(...)` | Prevents editing roles at or above the actor's highest role |
| `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields | | `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields |
| `hasRoomBanForUser(bans, user, persistedUserId?)` | Returns true when any active ban entry targets the provided user |
| `isRoomBanMatch(ban, user, persistedUserId?)` | Returns true when a single ban entry targets the provided user |
## Layering ## Layering

View File

@@ -1,6 +0,0 @@
export * from './access-control.models';
export * from './access-control.constants';
export * from './role.rules';
export * from './role-assignment.rules';
export * from './permission.rules';
export * from './room.rules';

View File

@@ -1,4 +1,4 @@
import type { RoomPermissionDefinition } from './access-control.models'; import type { RoomPermissionDefinition } from '../models/access-control.model';
export const SYSTEM_ROLE_IDS = { export const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone', everyone: 'system-everyone',

View File

@@ -2,7 +2,7 @@ import type {
RoomMember, RoomMember,
RoomPermissionKey, RoomPermissionKey,
User User
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
export interface RoomPermissionDefinition { export interface RoomPermissionDefinition {
key: RoomPermissionKey; key: RoomPermissionKey;

View File

@@ -1,4 +1,4 @@
import { BanEntry, User } from '../models/index'; import { BanEntry, User } from '../../../../shared-kernel';
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined; type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;

View File

@@ -4,9 +4,9 @@ import {
Room, Room,
RoomPermissionKey, RoomPermissionKey,
RoomRole RoomRole
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
import { import {
buildRoleLookup, buildRoleLookup,
getRolePermissionState, getRolePermissionState,
@@ -14,7 +14,7 @@ import {
normalizePermissionState, normalizePermissionState,
roleSortAscending, roleSortAscending,
compareText compareText
} from './access-control.internal'; } from '../util/access-control.util';
import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules'; import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules';
import { getRoomRoleById, normalizeRoomRoles } from './role.rules'; import { getRoomRoleById, normalizeRoomRoles } from './role.rules';

View File

@@ -3,9 +3,9 @@ import {
RoomMember, RoomMember,
RoomRole, RoomRole,
RoomRoleAssignment RoomRoleAssignment
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
import { import {
buildRoleLookup, buildRoleLookup,
compareText, compareText,
@@ -13,7 +13,7 @@ import {
matchesIdentity, matchesIdentity,
roleSortDescending, roleSortDescending,
uniqueStrings uniqueStrings
} from './access-control.internal'; } from '../util/access-control.util';
import { getRoomRoleById, normalizeRoomRoles } from './role.rules'; import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] { function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] {

View File

@@ -2,8 +2,8 @@ import {
RoomPermissionMatrix, RoomPermissionMatrix,
RoomPermissions, RoomPermissions,
RoomRole RoomRole
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import { import {
buildRoleLookup, buildRoleLookup,
buildSystemRole, buildSystemRole,
@@ -12,7 +12,7 @@ import {
normalizePermissionMatrix, normalizePermissionMatrix,
roleSortAscending, roleSortAscending,
roleSortDescending roleSortDescending
} from './access-control.internal'; } from '../util/access-control.util';
const ROLE_COLORS = { const ROLE_COLORS = {
everyone: '#6b7280', everyone: '#6b7280',

View File

@@ -7,14 +7,14 @@ import {
RoomRole, RoomRole,
RoomRoleAssignment, RoomRoleAssignment,
UserRole UserRole
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import { import {
getRolePermissionState, getRolePermissionState,
permissionStateToBoolean, permissionStateToBoolean,
resolveLegacyAllowState resolveLegacyAllowState
} from './access-control.internal'; } from '../util/access-control.util';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
import { import {
getAssignedRoleIds, getAssignedRoleIds,
normalizeRoomRoleAssignments, normalizeRoomRoleAssignments,

View File

@@ -5,8 +5,8 @@ import {
RoomRole, RoomRole,
RoomRoleAssignment, RoomRoleAssignment,
ROOM_PERMISSION_KEYS ROOM_PERMISSION_KEYS
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
export function normalizeName(name: string): string { export function normalizeName(name: string): string {
return name.trim().replace(/\s+/g, ' '); return name.trim().replace(/\s+/g, ' ');

View File

@@ -1,6 +1,7 @@
export * from './domain/access-control.models'; export * from './domain/models/access-control.model';
export * from './domain/access-control.constants'; export * from './domain/constants/access-control.constants';
export * from './domain/role.rules'; export * from './domain/rules/role.rules';
export * from './domain/role-assignment.rules'; export * from './domain/rules/role-assignment.rules';
export * from './domain/permission.rules'; export * from './domain/rules/permission.rules';
export * from './domain/room.rules'; export * from './domain/rules/room.rules';
export * from './domain/rules/ban.rules';

View File

@@ -7,25 +7,32 @@ Handles file sharing between peers over WebRTC data channels. Files are announce
``` ```
attachment/ attachment/
├── application/ ├── application/
│ ├── attachment.facade.ts Thin entry point, delegates to manager │ ├── facades/
── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners │ └── attachment.facade.ts Thin entry point, delegates to manager
── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel) ── services/
│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending) ── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
├── domain/ ├── domain/
│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state │ ├── logic/
── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment │ └── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB │ ├── models/
│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...) │ ├── attachment.model.ts Attachment type extending AttachmentMeta with runtime state
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages │ └── attachment-transfer.model.ts Protocol event types (file-announce, file-chunk, file-request, ...)
│ └── constants/
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
├── infrastructure/ ├── infrastructure/
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete) │ ├── services/
│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket │ └── attachment-storage.service.ts Electron filesystem access (save / read / delete)
│ └── util/
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
└── index.ts Barrel exports └── index.ts Barrel exports
``` ```
## Service composition ## Service composition
@@ -52,17 +59,17 @@ graph TD
Transfer --> Store Transfer --> Store
Persistence --> Storage Persistence --> Storage
Persistence --> Store Persistence --> Store
Storage --> Helpers[attachment-storage.helpers] Storage --> Helpers[attachment-storage.util]
click Facade "application/attachment.facade.ts" "Thin entry point" _blank click Facade "application/facades/attachment.facade.ts" "Thin entry point" _blank
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank click Manager "application/services/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank click Transfer "application/services/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank click Transport "application/services/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank click Persistence "application/services/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank click Store "application/services/attachment-runtime.store.ts" "In-memory signal-based state" _blank
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank click Storage "infrastructure/services/attachment-storage.service.ts" "Electron filesystem access" _blank
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank click Helpers "infrastructure/util/attachment-storage.util.ts" "Path helpers" _blank
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank click Logic "domain/logic/attachment.logic.ts" "Pure decision functions" _blank
``` ```
## File transfer protocol ## File transfer protocol

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { AttachmentManagerService } from './attachment-manager.service'; import { AttachmentManagerService } from '../services/attachment-manager.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentFacade { export class AttachmentFacade {

View File

@@ -4,18 +4,18 @@ import {
inject inject
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { ROOM_URL_PATTERN } from '../../../core/constants'; import { ROOM_URL_PATTERN } from '../../../../core/constants';
import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic'; import { shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import type { import type {
FileAnnouncePayload, FileAnnouncePayload,
FileCancelPayload, FileCancelPayload,
FileChunkPayload, FileChunkPayload,
FileNotFoundPayload, FileNotFoundPayload,
FileRequestPayload FileRequestPayload
} from '../domain/attachment-transfer.models'; } from '../../domain/models/attachment-transfer.model';
import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service'; import { AttachmentTransferService } from './attachment-transfer.service';

View File

@@ -1,12 +1,12 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs'; import { take } from 'rxjs';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
import { DatabaseService } from '../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants'; import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -1,5 +1,5 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import type { Attachment } from '../domain/attachment.models'; import type { Attachment } from '../../domain/models/attachment.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentRuntimeStore { export class AttachmentRuntimeStore {

View File

@@ -1,8 +1,8 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants'; import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
import { FileChunkEvent } from '../domain/attachment-transfer.models'; import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService { export class AttachmentTransferTransportService {

View File

@@ -1,17 +1,17 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics'; import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime/logging/debug-network-metrics';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic'; import { shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT, ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT, ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE, DEFAULT_ATTACHMENT_MIME_TYPE,
FILE_NOT_FOUND_REQUEST_ERROR, FILE_NOT_FOUND_REQUEST_ERROR,
NO_CONNECTED_PEERS_REQUEST_ERROR NO_CONNECTED_PEERS_REQUEST_ERROR
} from '../domain/attachment-transfer.constants'; } from '../../domain/constants/attachment-transfer.constants';
import { import {
type FileAnnounceEvent, type FileAnnounceEvent,
type FileAnnouncePayload, type FileAnnouncePayload,
@@ -23,7 +23,7 @@ import {
type FileRequestEvent, type FileRequestEvent,
type FileRequestPayload, type FileRequestPayload,
type LocalFileWithPath type LocalFileWithPath
} from '../domain/attachment-transfer.models'; } from '../../domain/models/attachment-transfer.model';
import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';

View File

@@ -1,5 +1,5 @@
import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants';
import type { Attachment } from './attachment.models'; import type { Attachment } from '../models/attachment.model';
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean { export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('image/') || return attachment.mime.startsWith('image/') ||

View File

@@ -1,5 +1,5 @@
import type { ChatEvent } from '../../../shared-kernel'; import type { ChatEvent } from '../../../../shared-kernel';
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel'; import type { ChatAttachmentAnnouncement } from '../../../../shared-kernel';
export type FileAnnounceEvent = ChatEvent & { export type FileAnnounceEvent = ChatEvent & {
type: 'file-announce'; type: 'file-announce';

View File

@@ -1,4 +1,4 @@
import type { ChatAttachmentMeta } from '../../../shared-kernel'; import type { ChatAttachmentMeta } from '../../../../shared-kernel';
export type AttachmentMeta = ChatAttachmentMeta; export type AttachmentMeta = ChatAttachmentMeta;

View File

@@ -1,3 +1,3 @@
export * from './application/attachment.facade'; export * from './application/facades/attachment.facade';
export * from './domain/attachment.constants'; export * from './domain/constants/attachment.constants';
export * from './domain/attachment.models'; export * from './domain/models/attachment.model';

View File

@@ -1,11 +1,11 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../domain/attachment.models'; import type { Attachment } from '../../domain/models/attachment.model';
import { import {
resolveAttachmentStorageBucket, resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename, resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName sanitizeAttachmentRoomName
} from './attachment-storage.helpers'; } from '../util/attachment-storage.util';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentStorageService { export class AttachmentStorageService {

View File

@@ -1 +0,0 @@
export * from './application/auth.service';

View File

@@ -1,13 +1,18 @@
# Auth Domain # Authentication Domain
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components. Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
## Module map ## Module map
``` ```
auth/ authentication/
├── application/ ├── application/
│ └── auth.service.ts HTTP login/register against the active server endpoint │ └── services/
│ └── authentication.service.ts HTTP login/register against the active server endpoint
├── domain/
│ └── models/
│ └── authentication.model.ts LoginResponse interface
├── feature/ ├── feature/
│ ├── login/ Login form component │ ├── login/ Login form component
@@ -19,14 +24,14 @@ auth/
## Service overview ## Service overview
`AuthService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store. `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.
```mermaid ```mermaid
graph TD graph TD
Login[LoginComponent] Login[LoginComponent]
Register[RegisterComponent] Register[RegisterComponent]
UserBar[UserBarComponent] UserBar[UserBarComponent]
Auth[AuthService] Auth[AuthenticationService]
SD[ServerDirectoryFacade] SD[ServerDirectoryFacade]
Store[NgRx Store] Store[NgRx Store]
@@ -36,7 +41,7 @@ graph TD
Auth --> SD Auth --> SD
Login --> Store Login --> Store
click Auth "application/auth.service.ts" "HTTP login/register" _blank click Auth "application/services/authentication.service.ts" "HTTP login/register" _blank
click Login "feature/login/" "Login form" _blank click Login "feature/login/" "Login form" _blank
click Register "feature/register/" "Registration form" _blank click Register "feature/register/" "Registration form" _blank
click UserBar "feature/user-bar/" "Current user display" _blank click UserBar "feature/user-bar/" "Current user display" _blank
@@ -49,7 +54,7 @@ graph TD
sequenceDiagram sequenceDiagram
participant User participant User
participant Login as LoginComponent participant Login as LoginComponent
participant Auth as AuthService participant Auth as AuthenticationService
participant SD as ServerDirectoryFacade participant SD as ServerDirectoryFacade
participant API as Server API participant API as Server API
participant Store as NgRx Store participant Store as NgRx Store

View File

@@ -2,19 +2,8 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory'; import { type ServerEndpoint, ServerDirectoryFacade } from '../../../server-directory';
import type { LoginResponse } from '../../domain/models/authentication.model';
/**
* Response returned by the authentication endpoints (login / register).
*/
export interface LoginResponse {
/** Unique user identifier assigned by the server. */
id: string;
/** Login username. */
username: string;
/** Human-readable display name. */
displayName: string;
}
/** /**
* Handles user authentication (login and registration) against a * Handles user authentication (login and registration) against a
@@ -25,7 +14,7 @@ export interface LoginResponse {
* server endpoint is used. * server endpoint is used.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthenticationService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly serverDirectory = inject(ServerDirectoryFacade);

View File

@@ -0,0 +1,11 @@
/**
* Response returned by the authentication endpoints (login / register).
*/
export interface LoginResponse {
/** Unique user identifier assigned by the server. */
id: string;
/** Login username. */
username: string;
/** Human-readable display name. */
displayName: string;
}

View File

@@ -11,7 +11,7 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide'; import { lucideLogIn } from '@ng-icons/lucide';
import { AuthService } from '../../application/auth.service'; import { AuthenticationService } from '../../application/services/authentication.service';
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
@@ -40,7 +40,7 @@ export class LoginComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id; serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null); error = signal<string | null>(null);
private auth = inject(AuthService); private auth = inject(AuthenticationService);
private store = inject(Store); private store = inject(Store);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);

View File

@@ -11,7 +11,7 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide'; import { lucideUserPlus } from '@ng-icons/lucide';
import { AuthService } from '../../application/auth.service'; import { AuthenticationService } from '../../application/services/authentication.service';
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
@@ -41,7 +41,7 @@ export class RegisterComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id; serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null); error = signal<string | null>(null);
private auth = inject(AuthService); private auth = inject(AuthenticationService);
private store = inject(Store); private store = inject(Store);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);

View File

@@ -0,0 +1,2 @@
export * from './application/services/authentication.service';
export * from './domain/models/authentication.model';

View File

@@ -7,11 +7,14 @@ Text messaging, reactions, GIF search, typing indicators, and the user list. All
``` ```
chat/ chat/
├── application/ ├── application/
│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server) │ └── services/
│ ├── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
│ └── link-metadata.service.ts Link preview metadata fetching
├── domain/ ├── domain/
── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp ── rules/
── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits ── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
├── feature/ ├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays) │ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
@@ -25,6 +28,7 @@ chat/
│ │ └── services/ │ │ └── services/
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering │ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
│ │ │ │
│ ├── chat-image-proxy-fallback.directive.ts Image proxy fallback for broken URLs
│ ├── klipy-gif-picker/ GIF search/browse picker panel │ ├── klipy-gif-picker/ GIF search/browse picker panel
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names) │ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
│ └── user-list/ Online user sidebar │ └── user-list/ Online user sidebar
@@ -129,7 +133,7 @@ graph LR
Klipy --> API Klipy --> API
click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank click Klipy "application/services/klipy.service.ts" "GIF search via KLIPY API" _blank
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
``` ```

View File

@@ -13,7 +13,7 @@ import {
throwError throwError
} from 'rxjs'; } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryFacade } from '../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
export interface KlipyGif { export interface KlipyGif {
id: string; id: string;

View File

@@ -1,8 +1,8 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { ServerDirectoryFacade } from '../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { LinkMetadata } from '../../../shared-kernel'; import { LinkMetadata } from '../../../../shared-kernel';
const URL_PATTERN = /https?:\/\/[^\s<>)"']+/g; const URL_PATTERN = /https?:\/\/[^\s<>)"']+/g;

View File

@@ -1,4 +1,4 @@
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel'; import { DELETED_MESSAGE_CONTENT, type Message } from '../../../../shared-kernel';
/** Extracts the effective timestamp from a message (editedAt takes priority). */ /** Extracts the effective timestamp from a message (editedAt takes priority). */
export function getMessageTimestamp(msg: Message): number { export function getMessageTimestamp(msg: Message): number {

View File

@@ -7,7 +7,7 @@ import {
input, input,
signal signal
} from '@angular/core'; } from '@angular/core';
import { KlipyService } from '../application/klipy.service'; import { KlipyService } from '../application/services/klipy.service';
@Directive({ @Directive({
selector: 'img[appChatImageProxyFallback]', selector: 'img[appChatImageProxyFallback]',

View File

@@ -11,7 +11,7 @@ import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif } from '../../application/klipy.service'; import { KlipyGif } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { import {
selectAllMessages, selectAllMessages,
@@ -33,7 +33,7 @@ import {
ChatMessageImageContextMenuEvent, ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent, ChatMessageReactionEvent,
ChatMessageReplyEvent ChatMessageReplyEvent
} from './models/chat-messages.models'; } from './models/chat-messages.model';
@Component({ @Component({
selector: 'app-chat-messages', selector: 'app-chat-messages',

View File

@@ -21,12 +21,12 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models'; import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/klipy.service'; import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel'; import { Message } from '../../../../../../shared-kernel';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component'; import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service'; import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models'; import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model';
type LocalFileWithPath = File & { type LocalFileWithPath = File & {
path?: string; path?: string;

View File

@@ -29,7 +29,7 @@ import {
AttachmentFacade, AttachmentFacade,
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment'; } from '../../../../../attachment';
import { KlipyService } from '../../../../application/klipy.service'; import { KlipyService } from '../../../../application/services/klipy.service';
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel'; import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
@@ -45,7 +45,7 @@ import {
ChatMessageImageContextMenuEvent, ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent, ChatMessageReactionEvent,
ChatMessageReplyEvent ChatMessageReplyEvent
} from '../../models/chat-messages.models'; } from '../../models/chat-messages.model';
const COMMON_EMOJIS = [ const COMMON_EMOJIS = [
'👍', '👍',

View File

@@ -13,7 +13,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { Attachment } from '../../../../../attachment'; import { Attachment } from '../../../../../attachment';
import { getMessageTimestamp } from '../../../../domain/message.rules'; import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
import { Message } from '../../../../../../shared-kernel'; import { Message } from '../../../../../../shared-kernel';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
@@ -22,7 +22,7 @@ import {
ChatMessageImageContextMenuEvent, ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent, ChatMessageReactionEvent,
ChatMessageReplyEvent ChatMessageReplyEvent
} from '../../models/chat-messages.models'; } from '../../models/chat-messages.model';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component'; import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal { interface PrismGlobal {

View File

@@ -12,7 +12,7 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { Attachment } from '../../../../../attachment'; import { Attachment } from '../../../../../attachment';
import { ContextMenuComponent } from '../../../../../../shared'; import { ContextMenuComponent } from '../../../../../../shared';
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models'; import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.model';
@Component({ @Component({
selector: 'app-chat-message-overlays', selector: 'app-chat-message-overlays',

View File

@@ -20,7 +20,7 @@ import {
lucideSearch, lucideSearch,
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../application/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
const KLIPY_CARD_MIN_WIDTH = 140; const KLIPY_CARD_MIN_WIDTH = 140;

View File

@@ -1,6 +1,7 @@
export * from './application/klipy.service'; export * from './application/services/klipy.service';
export * from './domain/message.rules'; export * from './application/services/link-metadata.service';
export * from './domain/message-sync.rules'; export * from './domain/rules/message.rules';
export * from './domain/rules/message-sync.rules';
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component'; export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component'; export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component'; export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';

View File

@@ -7,16 +7,23 @@ Owns desktop notification delivery, unread tracking, mute preferences, and the n
``` ```
notifications/ notifications/
├── application/ ├── application/
│ ├── notifications.facade.ts Stateful domain boundary: settings, unread counts, read markers, delivery decisions │ ├── facades/
│ └── notifications.effects.ts NgRx glue reacting to room, user, and message actions │ └── notifications.facade.ts Thin domain boundary, delegates to NotificationsService
│ ├── services/
│ │ └── notifications.service.ts Stateful orchestrator: settings, unread counts, read markers, delivery decisions
│ └── effects/
│ └── notifications.effects.ts NgRx glue reacting to room, user, and message actions
├── domain/ ├── domain/
│ ├── notification.logic.ts Pure rules for mute checks, visibility, preview formatting, unread aggregation │ ├── logic/
│ └── notification.models.ts Settings, unread state, delivery context, and payload contracts │ └── notification.logic.ts Pure rules for mute checks, visibility, preview formatting, unread aggregation
│ └── models/
│ └── notification.model.ts Settings, unread state, delivery context, and payload contracts
├── infrastructure/ ├── infrastructure/
── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention ── services/
└── notification-settings.storage.ts localStorage persistence with defensive deserialisation ├── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention
│ └── notification-settings-storage.service.ts localStorage persistence with defensive deserialisation
├── feature/ ├── feature/
│ └── settings/ │ └── settings/
@@ -36,6 +43,7 @@ graph TD
Rail[ServersRailComponent] Rail[ServersRailComponent]
Sidebar[RoomsSidePanelComponent] Sidebar[RoomsSidePanelComponent]
Facade[NotificationsFacade] Facade[NotificationsFacade]
Service[NotificationsService]
Logic[notification.logic] Logic[notification.logic]
Storage[NotificationSettingsStorageService] Storage[NotificationSettingsStorageService]
DB[DatabaseService] DB[DatabaseService]
@@ -49,17 +57,19 @@ graph TD
Settings --> Facade Settings --> Facade
Rail --> Facade Rail --> Facade
Sidebar --> Facade Sidebar --> Facade
Facade --> Logic Facade --> Service
Facade --> Storage Service --> Logic
Facade --> DB Service --> Storage
Facade --> Desktop Service --> DB
Facade --> Audio Service --> Desktop
Service --> Audio
click Facade "application/notifications.facade.ts" "Stateful domain boundary" _blank click Facade "application/facades/notifications.facade.ts" "Thin domain boundary" _blank
click Effects "application/notifications.effects.ts" "NgRx glue" _blank click Service "application/services/notifications.service.ts" "Stateful orchestrator" _blank
click Logic "domain/notification.logic.ts" "Pure notification rules" _blank click Effects "application/effects/notifications.effects.ts" "NgRx glue" _blank
click Storage "infrastructure/notification-settings.storage.ts" "localStorage persistence" _blank click Logic "domain/logic/notification.logic.ts" "Pure notification rules" _blank
click Desktop "infrastructure/desktop-notification.service.ts" "Desktop notification adapter" _blank click Storage "infrastructure/services/notification-settings-storage.service.ts" "localStorage persistence" _blank
click Desktop "infrastructure/services/desktop-notification.service.ts" "Desktop notification adapter" _blank
click Settings "feature/settings/notifications-settings.component.ts" "Notifications settings UI" _blank click Settings "feature/settings/notifications-settings.component.ts" "Notifications settings UI" _blank
click DB "../../infrastructure/persistence/database.service.ts" "Persistence facade" _blank click DB "../../infrastructure/persistence/database.service.ts" "Persistence facade" _blank
``` ```
@@ -68,7 +78,7 @@ graph TD
The domain has two runtime entry points: The domain has two runtime entry points:
- `NotificationsFacade` is injected directly by app bootstrapping and feature components. - `NotificationsFacade` is injected directly by app bootstrapping and feature components. It is a thin pass-through that delegates to `NotificationsService`.
- `NotificationsEffects` is registered globally in `provideEffects(...)` and forwards store actions into the facade. - `NotificationsEffects` is registered globally in `provideEffects(...)` and forwards store actions into the facade.
All effects in this domain are `dispatch: false`. The effect layer never owns notification business rules; it only connects NgRx actions to `NotificationsFacade`. All effects in this domain are `dispatch: false`. The effect layer never owns notification business rules; it only connects NgRx actions to `NotificationsFacade`.

View File

@@ -11,12 +11,12 @@ import {
tap, tap,
withLatestFrom withLatestFrom
} from 'rxjs/operators'; } from 'rxjs/operators';
import { MessagesActions } from '../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { selectCurrentRoom, selectSavedRooms } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { NotificationsFacade } from './notifications.facade'; import { NotificationsFacade } from '../facades/notifications.facade';
@Injectable() @Injectable()
export class NotificationsEffects { export class NotificationsEffects {

View File

@@ -0,0 +1,101 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { NotificationsService } from '../services/notifications.service';
@Injectable({ providedIn: 'root' })
export class NotificationsFacade {
private readonly service = inject(NotificationsService);
readonly settings = this.service.settings;
readonly unread = this.service.unread;
initialize(
...args: Parameters<NotificationsService['initialize']>
): ReturnType<NotificationsService['initialize']> {
return this.service.initialize(...args);
}
syncRoomCatalog(
...args: Parameters<NotificationsService['syncRoomCatalog']>
): ReturnType<NotificationsService['syncRoomCatalog']> {
return this.service.syncRoomCatalog(...args);
}
hydrateUnreadCounts(
...args: Parameters<NotificationsService['hydrateUnreadCounts']>
): ReturnType<NotificationsService['hydrateUnreadCounts']> {
return this.service.hydrateUnreadCounts(...args);
}
refreshRoomUnreadFromMessages(
...args: Parameters<NotificationsService['refreshRoomUnreadFromMessages']>
): ReturnType<NotificationsService['refreshRoomUnreadFromMessages']> {
return this.service.refreshRoomUnreadFromMessages(...args);
}
handleIncomingMessage(
...args: Parameters<NotificationsService['handleIncomingMessage']>
): ReturnType<NotificationsService['handleIncomingMessage']> {
return this.service.handleIncomingMessage(...args);
}
markCurrentChannelReadIfActive(
...args: Parameters<NotificationsService['markCurrentChannelReadIfActive']>
): ReturnType<NotificationsService['markCurrentChannelReadIfActive']> {
return this.service.markCurrentChannelReadIfActive(...args);
}
isRoomMuted(
...args: Parameters<NotificationsService['isRoomMuted']>
): ReturnType<NotificationsService['isRoomMuted']> {
return this.service.isRoomMuted(...args);
}
isChannelMuted(
...args: Parameters<NotificationsService['isChannelMuted']>
): ReturnType<NotificationsService['isChannelMuted']> {
return this.service.isChannelMuted(...args);
}
roomUnreadCount(
...args: Parameters<NotificationsService['roomUnreadCount']>
): ReturnType<NotificationsService['roomUnreadCount']> {
return this.service.roomUnreadCount(...args);
}
channelUnreadCount(
...args: Parameters<NotificationsService['channelUnreadCount']>
): ReturnType<NotificationsService['channelUnreadCount']> {
return this.service.channelUnreadCount(...args);
}
setNotificationsEnabled(
...args: Parameters<NotificationsService['setNotificationsEnabled']>
): ReturnType<NotificationsService['setNotificationsEnabled']> {
return this.service.setNotificationsEnabled(...args);
}
setShowPreview(
...args: Parameters<NotificationsService['setShowPreview']>
): ReturnType<NotificationsService['setShowPreview']> {
return this.service.setShowPreview(...args);
}
setRespectBusyStatus(
...args: Parameters<NotificationsService['setRespectBusyStatus']>
): ReturnType<NotificationsService['setRespectBusyStatus']> {
return this.service.setRespectBusyStatus(...args);
}
setRoomMuted(
...args: Parameters<NotificationsService['setRoomMuted']>
): ReturnType<NotificationsService['setRoomMuted']> {
return this.service.setRoomMuted(...args);
}
setChannelMuted(
...args: Parameters<NotificationsService['setChannelMuted']>
): ReturnType<NotificationsService['setChannelMuted']> {
return this.service.setChannelMuted(...args);
}
}

View File

@@ -6,15 +6,15 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import type { Message, Room } from '../../../shared-kernel'; import type { Message, Room } from '../../../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { DatabaseService } from '../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { import {
selectActiveChannelId, selectActiveChannelId,
selectCurrentRoom, selectCurrentRoom,
selectSavedRooms selectSavedRooms
} from '../../../store/rooms/rooms.selectors'; } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { import {
buildNotificationDisplayPayload, buildNotificationDisplayPayload,
calculateUnreadForRoom, calculateUnreadForRoom,
@@ -27,23 +27,23 @@ import {
isMessageVisibleInActiveView, isMessageVisibleInActiveView,
resolveMessageChannelId, resolveMessageChannelId,
shouldDeliverNotification shouldDeliverNotification
} from '../domain/notification.logic'; } from '../../domain/logic/notification.logic';
import { import {
createDefaultNotificationSettings, createDefaultNotificationSettings,
createEmptyUnreadState, createEmptyUnreadState,
type NotificationDeliveryContext, type NotificationDeliveryContext,
type NotificationsSettings, type NotificationsSettings,
type NotificationsUnreadState type NotificationsUnreadState
} from '../domain/notification.models'; } from '../../domain/models/notification.model';
import { DesktopNotificationService } from '../infrastructure/desktop-notification.service'; import { DesktopNotificationService } from '../../infrastructure/services/desktop-notification.service';
import { NotificationSettingsStorageService } from '../infrastructure/notification-settings.storage'; import { NotificationSettingsStorageService } from '../../infrastructure/services/notification-settings-storage.service';
type DesktopPlatform = 'linux' | 'mac' | 'unknown' | 'windows'; type DesktopPlatform = 'linux' | 'mac' | 'unknown' | 'windows';
const MAX_NOTIFIED_MESSAGE_IDS = 500; const MAX_NOTIFIED_MESSAGE_IDS = 500;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class NotificationsFacade { export class NotificationsService {
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly db = inject(DatabaseService); private readonly db = inject(DatabaseService);
private readonly audio = inject(NotificationAudioService); private readonly audio = inject(NotificationAudioService);

View File

@@ -1,10 +1,10 @@
import type { Message, Room } from '../../../shared-kernel'; import type { Message, Room } from '../../../../shared-kernel';
import type { import type {
NotificationDeliveryContext, NotificationDeliveryContext,
NotificationDisplayPayload, NotificationDisplayPayload,
NotificationsSettings, NotificationsSettings,
RoomUnreadCounts RoomUnreadCounts
} from './notification.models'; } from '../models/notification.model';
export const DEFAULT_TEXT_CHANNEL_ID = 'general'; export const DEFAULT_TEXT_CHANNEL_ID = 'general';

View File

@@ -2,7 +2,7 @@ import type {
Message, Message,
Room, Room,
User User
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
export interface NotificationsSettings { export interface NotificationsSettings {
enabled: boolean; enabled: boolean;

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { WindowStateSnapshot } from '../../../core/platform/electron/electron-api.models'; import type { WindowStateSnapshot } from '../../../../core/platform/electron/electron-api.models';
import { PlatformService } from '../../../core/platform'; import { PlatformService } from '../../../../core/platform';
import type { NotificationDisplayPayload } from '../domain/notification.models'; import type { NotificationDisplayPayload } from '../../domain/models/notification.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DesktopNotificationService { export class DesktopNotificationService {

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../core/constants'; import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../../core/constants';
import { createDefaultNotificationSettings, type NotificationsSettings } from '../domain/notification.models'; import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class NotificationSettingsStorageService { export class NotificationSettingsStorageService {

View File

@@ -9,11 +9,14 @@ The mixed live-stream workspace is intentionally not part of this domain. It liv
``` ```
screen-share/ screen-share/
├── application/ ├── application/
│ ├── screen-share.facade.ts Proxy to RealtimeSessionFacade for screen share signals and methods │ ├── facades/
│ └── screen-share-source-picker.service.ts Electron desktop source picker (Promise-based open/confirm/cancel) │ └── screen-share.facade.ts Proxy to RealtimeSessionFacade for screen share signals and methods
│ └── services/
│ └── screen-share-source-picker.service.ts Electron desktop source picker (Promise-based open/confirm/cancel)
├── domain/ ├── domain/
│ └── screen-share.config.ts Quality presets and types (re-exported from shared-kernel) │ └── constants/
│ └── screen-share.constants.ts Quality presets and types (re-exported from shared-kernel)
├── feature/ ├── feature/
│ ├── screen-share-quality-dialog/ Quality preset picker before capture │ ├── screen-share-quality-dialog/ Quality preset picker before capture
@@ -30,7 +33,7 @@ graph TD
SSF[ScreenShareFacade] SSF[ScreenShareFacade]
Picker[ScreenShareSourcePickerService] Picker[ScreenShareSourcePickerService]
RSF[RealtimeSessionFacade] RSF[RealtimeSessionFacade]
Config[screen-share.config] Config[screen-share.constants]
Viewer[ScreenShareViewerComponent] Viewer[ScreenShareViewerComponent]
Workspace[VoiceWorkspaceComponent] Workspace[VoiceWorkspaceComponent]
@@ -39,12 +42,12 @@ graph TD
Workspace --> SSF Workspace --> SSF
Picker --> Config Picker --> Config
click SSF "application/screen-share.facade.ts" "Proxy to RealtimeSessionFacade" _blank click SSF "application/facades/screen-share.facade.ts" "Proxy to RealtimeSessionFacade" _blank
click Picker "application/screen-share-source-picker.service.ts" "Electron source picker" _blank click Picker "application/services/screen-share-source-picker.service.ts" "Electron source picker" _blank
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank
click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player" _blank click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player" _blank
click Workspace "../../features/room/voice-workspace/voice-workspace.component.ts" "Room-level live stream workspace" _blank click Workspace "../../features/room/voice-workspace/voice-workspace.component.ts" "Room-level live stream workspace" _blank
click Config "domain/screen-share.config.ts" "Quality presets" _blank click Config "domain/constants/screen-share.constants.ts" "Quality presets" _blank
``` ```
## Starting a screen share ## Starting a screen share

View File

@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { ScreenShareStartOptions } from '../domain/screen-share.config'; import { ScreenShareStartOptions } from '../../domain/constants/screen-share.constants';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ScreenShareFacade { export class ScreenShareFacade {

View File

@@ -3,8 +3,8 @@ import {
computed, computed,
signal signal
} from '@angular/core'; } from '@angular/core';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../voice-session';
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config'; import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../domain/constants/screen-share.constants';
export type ScreenShareSourceKind = 'screen' | 'window'; export type ScreenShareSourceKind = 'screen' | 'window';

View File

@@ -7,7 +7,7 @@ import {
type ScreenShareQualityPreset, type ScreenShareQualityPreset,
type ScreenShareStartOptions, type ScreenShareStartOptions,
type ScreenShareQuality type ScreenShareQuality
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
export { export {
DEFAULT_SCREEN_SHARE_QUALITY, DEFAULT_SCREEN_SHARE_QUALITY,

View File

@@ -19,11 +19,11 @@ import {
lucideMonitor lucideMonitor
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ScreenShareFacade } from '../../application/screen-share.facade'; import { ScreenShareFacade } from '../../application/facades/screen-share.facade';
import { selectOnlineUsers } from '../../../../store/users/users.selectors'; import { selectOnlineUsers } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { DEFAULT_VOLUME } from '../../../../core/constants'; import { DEFAULT_VOLUME } from '../../../../core/constants';
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service'; import { VoicePlaybackService } from '../../../../domains/voice-connection';
@Component({ @Component({
selector: 'app-screen-share-viewer', selector: 'app-screen-share-viewer',

View File

@@ -1,6 +1,6 @@
export * from './application/screen-share.facade'; export * from './application/facades/screen-share.facade';
export * from './application/screen-share-source-picker.service'; export * from './application/services/screen-share-source-picker.service';
export * from './domain/screen-share.config'; export * from './domain/constants/screen-share.constants';
// Feature components // Feature components
export { ScreenShareViewerComponent } from './feature/screen-share-viewer/screen-share-viewer.component'; export { ScreenShareViewerComponent } from './feature/screen-share-viewer/screen-share-viewer.component';

View File

@@ -7,19 +7,30 @@ Manages the list of server endpoints the client can connect to, health-checking
``` ```
server-directory/ server-directory/
├── application/ ├── application/
│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation │ ├── facades/
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence │ └── server-directory.facade.ts Thin domain boundary, delegates to ServerDirectoryService
│ └── services/
│ ├── server-directory.service.ts Orchestrator: server CRUD, search, health, invites, moderation
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence
├── domain/ ├── domain/
│ ├── server-directory.models.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types │ ├── constants/
── server-directory.constants.ts CLIENT_UPDATE_REQUIRED_MESSAGE │ └── server-directory.constants.ts CLIENT_UPDATE_REQUIRED_MESSAGE
── server-endpoint-defaults.ts Default endpoint templates, URL sanitisation, reconciliation helpers ── logic/
│ │ ├── room-signal-source.logic.ts Room → signal-source selector resolution
│ │ ├── room-signal-source.logic.spec.ts Unit tests
│ │ └── server-endpoint-defaults.logic.ts Default endpoint templates, URL sanitisation, reconciliation helpers
│ └── models/
│ └── server-directory.model.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types
├── infrastructure/ ├── infrastructure/
│ ├── server-directory-api.service.ts HTTP client for all server API calls │ ├── constants/
── server-endpoint-health.service.ts Health probe (GET /api/health with 5 s timeout, fallback to /api/servers) │ └── server-directory.infrastructure.constants.ts Health-check timeout, localStorage keys
── server-endpoint-compatibility.service.ts Semantic version comparison for client/server compatibility ── services/
── server-endpoint-storage.service.ts localStorage read/write for endpoint list and removed-default tracking ── server-directory-api.service.ts HTTP client for all server API calls
│ ├── server-endpoint-compatibility.service.ts Semantic version comparison for client/server compatibility
│ ├── server-endpoint-health.service.ts Health probe (GET /api/health with 5 s timeout, fallback to /api/servers)
│ └── server-endpoint-storage.service.ts localStorage read/write for endpoint list and removed-default tracking
├── feature/ ├── feature/
│ ├── invite/ Invite creation and resolution UI │ ├── invite/ Invite creation and resolution UI
@@ -31,36 +42,39 @@ server-directory/
## Layer composition ## Layer composition
The facade delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service. The facade is a thin pass-through that delegates to `ServerDirectoryService`. The service delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service.
```mermaid ```mermaid
graph TD graph TD
Facade[ServerDirectoryFacade] Facade[ServerDirectoryFacade]
Service[ServerDirectoryService]
State[ServerEndpointStateService] State[ServerEndpointStateService]
API[ServerDirectoryApiService] API[ServerDirectoryApiService]
Health[ServerEndpointHealthService] Health[ServerEndpointHealthService]
Compat[ServerEndpointCompatibilityService] Compat[ServerEndpointCompatibilityService]
Storage[ServerEndpointStorageService] Storage[ServerEndpointStorageService]
Defaults[server-endpoint-defaults] Defaults[server-endpoint-defaults.logic]
Models[server-directory.models] Models[server-directory.model]
Facade --> API Facade --> Service
Facade --> State Service --> API
Facade --> Health Service --> State
Facade --> Compat Service --> Health
Service --> Compat
API --> State API --> State
State --> Storage State --> Storage
State --> Defaults State --> Defaults
Health --> Compat Health --> Compat
click Facade "application/server-directory.facade.ts" "High-level API" _blank click Facade "application/facades/server-directory.facade.ts" "Thin domain boundary" _blank
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state" _blank click Service "application/services/server-directory.service.ts" "Orchestrator" _blank
click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API" _blank click State "application/services/server-endpoint-state.service.ts" "Signal-based endpoint state" _blank
click Health "infrastructure/server-endpoint-health.service.ts" "Health probe" _blank click API "infrastructure/services/server-directory-api.service.ts" "HTTP client for server API" _blank
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility" _blank click Health "infrastructure/services/server-endpoint-health.service.ts" "Health probe" _blank
click Storage "infrastructure/server-endpoint-storage.service.ts" "localStorage persistence" _blank click Compat "infrastructure/services/server-endpoint-compatibility.service.ts" "Version compatibility" _blank
click Defaults "domain/server-endpoint-defaults.ts" "Default endpoint templates" _blank click Storage "infrastructure/services/server-endpoint-storage.service.ts" "localStorage persistence" _blank
click Models "domain/server-directory.models.ts" "Domain types" _blank click Defaults "domain/logic/server-endpoint-defaults.logic.ts" "Default endpoint templates" _blank
click Models "domain/models/server-directory.model.ts" "Domain types" _blank
``` ```
## Endpoint lifecycle ## Endpoint lifecycle
@@ -87,13 +101,18 @@ stateDiagram-v2
## Health probing ## Health probing
The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which: The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate through the service to `ServerEndpointHealthService.probeEndpoint()`, which:
1. Sends `GET /api/health` with a 5-second timeout 1. Sends `GET /api/health` with a 5-second timeout
2. On success, checks the response's `serverVersion` against the client version via `ServerEndpointCompatibilityService` 2. Reads the response's `serverVersion` and stable `serverInstanceId`
3. If versions are incompatible, the endpoint is marked `incompatible` and deactivated 3. Checks the reported version against the client version via `ServerEndpointCompatibilityService`
4. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check 4. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
5. Updates the endpoint's status, latency, and version info in the state service 5. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
6. Updates the endpoint's status, latency, and version info in the state service
`serverInstanceId` lets the client detect when multiple configured URLs point at the same backend. `ServerEndpointStateService.resolveCanonicalEndpoint()` prefers one canonical endpoint per backend instance so REST calls, WebSocket routing, and room fallback logic do not treat same-instance aliases as different signaling clusters.
Room signaling now waits for that initial health sweep before the first saved-room reconnect attempt. That avoids a cold-start race where alias endpoints could open separate WebSocket managers before `serverInstanceId` had been learned.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -106,7 +125,7 @@ sequenceDiagram
Health->>API: GET /api/health (5s timeout) Health->>API: GET /api/health (5s timeout)
alt 200 OK alt 200 OK
API-->>Health: { serverVersion } API-->>Health: { serverVersion, serverInstanceId }
Health->>Compat: evaluateServerVersion(serverVersion, clientVersion) Health->>Compat: evaluateServerVersion(serverVersion, clientVersion)
Compat-->>Health: { isCompatible, serverVersion } Compat-->>Health: { isCompatible, serverVersion }
Health-->>Facade: online / incompatible + latency + versions Health-->>Facade: online / incompatible + latency + versions
@@ -132,6 +151,10 @@ The facade's `searchServers(query)` method supports two modes controlled by a `s
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from. The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
That search fan-out is discovery only. Once a room is created or joined, the room keeps an authoritative signal-server affinity via its `sourceId` / `sourceUrl`. The join response can repair stale saved metadata, and reconnect logic now retries that authoritative endpoint first before probing any other configured endpoints.
Fallback stays temporary. If the authoritative endpoint is unavailable, the client can probe other active compatible endpoints as a last resort for the current session, but it does not rewrite the room's saved affinity to that fallback endpoint.
## Server-owned room metadata ## Server-owned room metadata
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins. `ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
@@ -147,6 +170,8 @@ Default servers are configured in the environment file. The state service builds
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking - `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
- The primary default URL is used as a fallback when no endpoint is resolved - The primary default URL is used as a fallback when no endpoint is resolved
Saved rooms can also self-heal their endpoint metadata. If a room has missing or stale source information, the client now searches the configured endpoints for that room, restores the correct source mapping, and persists the repair locally.
URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol. URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol.
## Server administration ## Server administration

View File

@@ -0,0 +1,229 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { ServerDirectoryService } from '../services/server-directory.service';
@Injectable({ providedIn: 'root' })
export class ServerDirectoryFacade {
private readonly service = inject(ServerDirectoryService);
readonly servers = this.service.servers;
readonly activeServers = this.service.activeServers;
readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers;
readonly activeServer = this.service.activeServer;
awaitInitialServerHealthCheck(
...args: Parameters<ServerDirectoryService['awaitInitialServerHealthCheck']>
): ReturnType<ServerDirectoryService['awaitInitialServerHealthCheck']> {
return this.service.awaitInitialServerHealthCheck(...args);
}
addServer(
...args: Parameters<ServerDirectoryService['addServer']>
): ReturnType<ServerDirectoryService['addServer']> {
return this.service.addServer(...args);
}
ensureServerEndpoint(
...args: Parameters<ServerDirectoryService['ensureServerEndpoint']>
): ReturnType<ServerDirectoryService['ensureServerEndpoint']> {
return this.service.ensureServerEndpoint(...args);
}
findServerByUrl(
...args: Parameters<ServerDirectoryService['findServerByUrl']>
): ReturnType<ServerDirectoryService['findServerByUrl']> {
return this.service.findServerByUrl(...args);
}
removeServer(
...args: Parameters<ServerDirectoryService['removeServer']>
): ReturnType<ServerDirectoryService['removeServer']> {
return this.service.removeServer(...args);
}
restoreDefaultServers(
...args: Parameters<ServerDirectoryService['restoreDefaultServers']>
): ReturnType<ServerDirectoryService['restoreDefaultServers']> {
return this.service.restoreDefaultServers(...args);
}
setActiveServer(
...args: Parameters<ServerDirectoryService['setActiveServer']>
): ReturnType<ServerDirectoryService['setActiveServer']> {
return this.service.setActiveServer(...args);
}
deactivateServer(
...args: Parameters<ServerDirectoryService['deactivateServer']>
): ReturnType<ServerDirectoryService['deactivateServer']> {
return this.service.deactivateServer(...args);
}
updateServerStatus(
...args: Parameters<ServerDirectoryService['updateServerStatus']>
): ReturnType<ServerDirectoryService['updateServerStatus']> {
return this.service.updateServerStatus(...args);
}
ensureEndpointVersionCompatibility(
...args: Parameters<ServerDirectoryService['ensureEndpointVersionCompatibility']>
): ReturnType<ServerDirectoryService['ensureEndpointVersionCompatibility']> {
return this.service.ensureEndpointVersionCompatibility(...args);
}
resolveRoomEndpoint(
...args: Parameters<ServerDirectoryService['resolveRoomEndpoint']>
): ReturnType<ServerDirectoryService['resolveRoomEndpoint']> {
return this.service.resolveRoomEndpoint(...args);
}
normaliseRoomSignalSource(
...args: Parameters<ServerDirectoryService['normaliseRoomSignalSource']>
): ReturnType<ServerDirectoryService['normaliseRoomSignalSource']> {
return this.service.normaliseRoomSignalSource(...args);
}
buildRoomSignalSelector(
...args: Parameters<ServerDirectoryService['buildRoomSignalSelector']>
): ReturnType<ServerDirectoryService['buildRoomSignalSelector']> {
return this.service.buildRoomSignalSelector(...args);
}
getFallbackRoomEndpoints(
...args: Parameters<ServerDirectoryService['getFallbackRoomEndpoints']>
): ReturnType<ServerDirectoryService['getFallbackRoomEndpoints']> {
return this.service.getFallbackRoomEndpoints(...args);
}
setSearchAllServers(
...args: Parameters<ServerDirectoryService['setSearchAllServers']>
): ReturnType<ServerDirectoryService['setSearchAllServers']> {
return this.service.setSearchAllServers(...args);
}
testServer(
...args: Parameters<ServerDirectoryService['testServer']>
): ReturnType<ServerDirectoryService['testServer']> {
return this.service.testServer(...args);
}
testAllServers(
...args: Parameters<ServerDirectoryService['testAllServers']>
): ReturnType<ServerDirectoryService['testAllServers']> {
return this.service.testAllServers(...args);
}
getApiBaseUrl(
...args: Parameters<ServerDirectoryService['getApiBaseUrl']>
): ReturnType<ServerDirectoryService['getApiBaseUrl']> {
return this.service.getApiBaseUrl(...args);
}
getWebSocketUrl(
...args: Parameters<ServerDirectoryService['getWebSocketUrl']>
): ReturnType<ServerDirectoryService['getWebSocketUrl']> {
return this.service.getWebSocketUrl(...args);
}
searchServers(
...args: Parameters<ServerDirectoryService['searchServers']>
): ReturnType<ServerDirectoryService['searchServers']> {
return this.service.searchServers(...args);
}
getServers(
...args: Parameters<ServerDirectoryService['getServers']>
): ReturnType<ServerDirectoryService['getServers']> {
return this.service.getServers(...args);
}
getServer(
...args: Parameters<ServerDirectoryService['getServer']>
): ReturnType<ServerDirectoryService['getServer']> {
return this.service.getServer(...args);
}
findServerAcrossActiveEndpoints(
...args: Parameters<ServerDirectoryService['findServerAcrossActiveEndpoints']>
): ReturnType<ServerDirectoryService['findServerAcrossActiveEndpoints']> {
return this.service.findServerAcrossActiveEndpoints(...args);
}
registerServer(
...args: Parameters<ServerDirectoryService['registerServer']>
): ReturnType<ServerDirectoryService['registerServer']> {
return this.service.registerServer(...args);
}
updateServer(
...args: Parameters<ServerDirectoryService['updateServer']>
): ReturnType<ServerDirectoryService['updateServer']> {
return this.service.updateServer(...args);
}
unregisterServer(
...args: Parameters<ServerDirectoryService['unregisterServer']>
): ReturnType<ServerDirectoryService['unregisterServer']> {
return this.service.unregisterServer(...args);
}
getServerUsers(
...args: Parameters<ServerDirectoryService['getServerUsers']>
): ReturnType<ServerDirectoryService['getServerUsers']> {
return this.service.getServerUsers(...args);
}
requestJoin(
...args: Parameters<ServerDirectoryService['requestJoin']>
): ReturnType<ServerDirectoryService['requestJoin']> {
return this.service.requestJoin(...args);
}
createInvite(
...args: Parameters<ServerDirectoryService['createInvite']>
): ReturnType<ServerDirectoryService['createInvite']> {
return this.service.createInvite(...args);
}
getInvite(
...args: Parameters<ServerDirectoryService['getInvite']>
): ReturnType<ServerDirectoryService['getInvite']> {
return this.service.getInvite(...args);
}
kickServerMember(
...args: Parameters<ServerDirectoryService['kickServerMember']>
): ReturnType<ServerDirectoryService['kickServerMember']> {
return this.service.kickServerMember(...args);
}
banServerMember(
...args: Parameters<ServerDirectoryService['banServerMember']>
): ReturnType<ServerDirectoryService['banServerMember']> {
return this.service.banServerMember(...args);
}
unbanServerMember(
...args: Parameters<ServerDirectoryService['unbanServerMember']>
): ReturnType<ServerDirectoryService['unbanServerMember']> {
return this.service.unbanServerMember(...args);
}
notifyLeave(
...args: Parameters<ServerDirectoryService['notifyLeave']>
): ReturnType<ServerDirectoryService['notifyLeave']> {
return this.service.notifyLeave(...args);
}
updateUserCount(
...args: Parameters<ServerDirectoryService['updateUserCount']>
): ReturnType<ServerDirectoryService['updateUserCount']> {
return this.service.updateUserCount(...args);
}
sendHeartbeat(
...args: Parameters<ServerDirectoryService['sendHeartbeat']>
): ReturnType<ServerDirectoryService['sendHeartbeat']> {
return this.service.sendHeartbeat(...args);
}
}

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