Compare commits
22 Commits
v1.0.88
...
b10004e154
| Author | SHA1 | Date | |
|---|---|---|---|
| b10004e154 | |||
| f33440a827 | |||
| 52912327ae | |||
| 7a0664b3c4 | |||
| cea3dccef1 | |||
| c8bb82feb5 | |||
| 3fb5515c3a | |||
| a6bdac1a25 | |||
| db7e683504 | |||
| 98ed8eeb68 | |||
| 39b85e2e3a | |||
| 58e338246f | |||
| 0b9a9f311e | |||
| 6800c73292 | |||
| ef1182d46f | |||
| 0865c2fe33 | |||
| 4a41de79d6 | |||
| 84fa45985a | |||
| 35352923a5 | |||
| b9df9c92f2 | |||
| 8674579b19 | |||
| de2d3300d4 |
63
.agents/skills/caveman/SKILL.md
Normal file
63
.agents/skills/caveman/SKILL.md
Normal 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.
|
||||||
282
.agents/skills/playwright-e2e/SKILL.md
Normal file
282
.agents/skills/playwright-e2e/SKILL.md
Normal 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 (0–200% 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 |
|
||||||
536
.agents/skills/playwright-e2e/reference/multi-client-webrtc.md
Normal file
536
.agents/skills/playwright-e2e/reference/multi-client-webrtc.md
Normal 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 (0–200%). 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 | 3–5s 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.
|
||||||
175
.agents/skills/playwright-e2e/reference/project-setup.md
Normal file
175
.agents/skills/playwright-e2e/reference/project-setup.md
Normal 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
4
.gitignore
vendored
@@ -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
4
e2e/fixtures/base.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
export const test = base;
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
57
e2e/fixtures/multi-client.ts
Normal file
57
e2e/fixtures/multi-client.ts
Normal 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';
|
||||||
77
e2e/helpers/seed-test-endpoint.ts
Normal file
77
e2e/helpers/seed-test-endpoint.ts
Normal 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);
|
||||||
|
}
|
||||||
95
e2e/helpers/start-test-server.js
Normal file
95
e2e/helpers/start-test-server.js
Normal 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);
|
||||||
134
e2e/helpers/webrtc-helpers.ts
Normal file
134
e2e/helpers/webrtc-helpers.ts
Normal 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
79
e2e/pages/chat-room.page.ts
Normal file
79
e2e/pages/chat-room.page.ts
Normal 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
29
e2e/pages/login.page.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
e2e/pages/register.page.ts
Normal file
35
e2e/pages/register.page.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
e2e/pages/server-search.page.ts
Normal file
65
e2e/pages/server-search.page.ts
Normal 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
52
e2e/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
238
e2e/tests/voice/voice-full-journey.spec.ts
Normal file
238
e2e/tests/voice/voice-full-journey.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
|
|||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
editedAt: message.editedAt ?? null,
|
editedAt: message.editedAt ?? null,
|
||||||
isDeleted: message.isDeleted ? 1 : 0,
|
isDeleted: message.isDeleted ? 1 : 0,
|
||||||
replyToId: message.replyToId ?? null
|
replyToId: message.replyToId ?? null,
|
||||||
|
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
|||||||
@@ -13,29 +13,35 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
|
|||||||
if (!existing)
|
if (!existing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (updates.channelId !== undefined)
|
const directFields = [
|
||||||
existing.channelId = updates.channelId ?? null;
|
'senderId',
|
||||||
|
'senderName',
|
||||||
|
'content',
|
||||||
|
'timestamp'
|
||||||
|
] as const;
|
||||||
|
const entity = existing as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
if (updates.senderId !== undefined)
|
for (const field of directFields) {
|
||||||
existing.senderId = updates.senderId;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field];
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.senderName !== undefined)
|
const nullableFields = [
|
||||||
existing.senderName = updates.senderName;
|
'channelId',
|
||||||
|
'editedAt',
|
||||||
|
'replyToId'
|
||||||
|
] as const;
|
||||||
|
|
||||||
if (updates.content !== undefined)
|
for (const field of nullableFields) {
|
||||||
existing.content = updates.content;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field] ?? null;
|
||||||
if (updates.timestamp !== undefined)
|
}
|
||||||
existing.timestamp = updates.timestamp;
|
|
||||||
|
|
||||||
if (updates.editedAt !== undefined)
|
|
||||||
existing.editedAt = updates.editedAt ?? null;
|
|
||||||
|
|
||||||
if (updates.isDeleted !== undefined)
|
if (updates.isDeleted !== undefined)
|
||||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||||
|
|
||||||
if (updates.replyToId !== undefined)
|
if (updates.linkMetadata !== undefined)
|
||||||
existing.replyToId = updates.replyToId ?? null;
|
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||||
|
|
||||||
await repo.save(existing);
|
await repo.save(existing);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
|
|||||||
editedAt: row.editedAt ?? undefined,
|
editedAt: row.editedAt ?? undefined,
|
||||||
reactions: isDeleted ? [] : reactions,
|
reactions: isDeleted ? [] : reactions,
|
||||||
isDeleted,
|
isDeleted,
|
||||||
replyToId: row.replyToId ?? undefined
|
replyToId: row.replyToId ?? undefined,
|
||||||
|
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface MessagePayload {
|
|||||||
reactions?: ReactionPayload[];
|
reactions?: ReactionPayload[];
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactionPayload {
|
export interface ReactionPayload {
|
||||||
|
|||||||
@@ -35,4 +35,7 @@ export class MessageEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
replyToId!: string | null;
|
replyToId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
linkMetadata!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
|
nativeImage,
|
||||||
|
net,
|
||||||
Notification,
|
Notification,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
@@ -503,4 +505,56 @@ export function setupSystemHandlers(): void {
|
|||||||
await fsp.mkdir(dirPath, { recursive: true });
|
await fsp.mkdir(dirPath, { recursive: true });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
|
||||||
|
if (typeof srcURL !== 'string' || !srcURL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const request = net.request(srcURL);
|
||||||
|
|
||||||
|
request.on('response', (response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
const image = nativeImage.createFromBuffer(Buffer.concat(chunks));
|
||||||
|
|
||||||
|
if (!image.isEmpty()) {
|
||||||
|
clipboard.writeImage(image);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
response.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', () => resolve(false));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddLinkMetadata1000000000005 implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "linkMetadata" text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// SQLite does not support DROP COLUMN; column is nullable and harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,22 @@ function readLinuxDisplayServer(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuParams {
|
||||||
|
posX: number;
|
||||||
|
posY: number;
|
||||||
|
isEditable: boolean;
|
||||||
|
selectionText: string;
|
||||||
|
linkURL: string;
|
||||||
|
mediaType: string;
|
||||||
|
srcURL: string;
|
||||||
|
editFlags: {
|
||||||
|
canCut: boolean;
|
||||||
|
canCopy: boolean;
|
||||||
|
canPaste: boolean;
|
||||||
|
canSelectAll: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -194,6 +210,10 @@ 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;
|
||||||
|
contextMenuCommand: (command: string) => Promise<void>;
|
||||||
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -299,6 +319,20 @@ const electronAPI: ElectronAPI = {
|
|||||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||||
|
|
||||||
|
onContextMenu: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => {
|
||||||
|
listener(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on('show-context-menu', wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener('show-context-menu', wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
||||||
|
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||||
|
|
||||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -264,6 +264,24 @@ export async function createWindow(): Promise<void> {
|
|||||||
|
|
||||||
emitWindowState();
|
emitWindowState();
|
||||||
|
|
||||||
|
mainWindow.webContents.on('context-menu', (_event, params) => {
|
||||||
|
mainWindow?.webContents.send('show-context-menu', {
|
||||||
|
posX: params.x,
|
||||||
|
posY: params.y,
|
||||||
|
isEditable: params.isEditable,
|
||||||
|
selectionText: params.selectionText,
|
||||||
|
linkURL: params.linkURL,
|
||||||
|
mediaType: params.mediaType,
|
||||||
|
srcURL: params.srcURL,
|
||||||
|
editFlags: {
|
||||||
|
canCut: params.editFlags.canCut,
|
||||||
|
canCopy: params.editFlags.canCopy,
|
||||||
|
canPaste: params.editFlags.canPaste,
|
||||||
|
canSelectAll: params.editFlags.canSelectAll
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
|
|||||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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.
@@ -4,18 +4,28 @@ import { resolveRuntimePath } from '../runtime-paths';
|
|||||||
|
|
||||||
export type ServerHttpProtocol = 'http' | 'https';
|
export type ServerHttpProtocol = 'http' | 'https';
|
||||||
|
|
||||||
|
export interface LinkPreviewConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
cacheTtlMinutes: number;
|
||||||
|
maxCacheSizeMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
serverPort: number;
|
||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
|
linkPreview: LinkPreviewConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||||
const DEFAULT_SERVER_PORT = 3001;
|
const DEFAULT_SERVER_PORT = 3001;
|
||||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||||
|
const DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES = 7200;
|
||||||
|
const DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
const HARD_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
|
||||||
function normalizeKlipyApiKey(value: unknown): string {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -66,6 +76,27 @@ function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): nu
|
|||||||
: fallback;
|
: fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
||||||
|
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const enabled = typeof raw.enabled === 'boolean'
|
||||||
|
? raw.enabled
|
||||||
|
: true;
|
||||||
|
const cacheTtl = typeof raw.cacheTtlMinutes === 'number'
|
||||||
|
&& Number.isFinite(raw.cacheTtlMinutes)
|
||||||
|
&& raw.cacheTtlMinutes >= 0
|
||||||
|
? raw.cacheTtlMinutes
|
||||||
|
: DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES;
|
||||||
|
const maxSize = typeof raw.maxCacheSizeMb === 'number'
|
||||||
|
&& Number.isFinite(raw.maxCacheSizeMb)
|
||||||
|
&& raw.maxCacheSizeMb >= 0
|
||||||
|
? Math.min(raw.maxCacheSizeMb, HARD_MAX_CACHE_SIZE_MB)
|
||||||
|
: DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB;
|
||||||
|
|
||||||
|
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||||
|
}
|
||||||
|
|
||||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -111,7 +142,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||||
|
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -124,7 +156,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
serverHost: normalized.serverHost
|
serverHost: normalized.serverHost,
|
||||||
|
linkPreview: normalized.linkPreview
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +202,7 @@ export function getServerHost(): string | undefined {
|
|||||||
export function isHttpsServerEnabled(): boolean {
|
export function isHttpsServerEnabled(): boolean {
|
||||||
return getServerProtocol() === 'https';
|
return getServerProtocol() === 'https';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||||
|
return getVariablesConfig().linkPreview;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Express } from 'express';
|
import { Express } from 'express';
|
||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
|
import linkMetadataRouter from './link-metadata';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
@@ -10,6 +11,7 @@ import { invitesApiRouter, invitePageRouter } from './invites';
|
|||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
|
app.use('/api', linkMetadataRouter);
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
|||||||
292
server/src/routes/link-metadata.ts
Normal file
292
server/src/routes/link-metadata.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getLinkPreviewConfig } from '../config/variables';
|
||||||
|
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
|
const MAX_HTML_BYTES = 512 * 1024;
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
const MAX_FIELD_LENGTH = 512;
|
||||||
|
|
||||||
|
interface CachedMetadata {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
siteName?: string;
|
||||||
|
failed?: boolean;
|
||||||
|
cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataCache = new Map<string, CachedMetadata>();
|
||||||
|
|
||||||
|
let cacheByteEstimate = 0;
|
||||||
|
|
||||||
|
function estimateEntryBytes(key: string, entry: CachedMetadata): number {
|
||||||
|
let bytes = key.length * 2;
|
||||||
|
|
||||||
|
if (entry.title)
|
||||||
|
bytes += entry.title.length * 2;
|
||||||
|
|
||||||
|
if (entry.description)
|
||||||
|
bytes += entry.description.length * 2;
|
||||||
|
|
||||||
|
if (entry.imageUrl)
|
||||||
|
bytes += entry.imageUrl.length * 2;
|
||||||
|
|
||||||
|
if (entry.siteName)
|
||||||
|
bytes += entry.siteName.length * 2;
|
||||||
|
|
||||||
|
return bytes + 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(key: string, entry: CachedMetadata): void {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
const maxBytes = config.maxCacheSizeMb * BYTES_PER_MB;
|
||||||
|
|
||||||
|
if (metadataCache.has(key)) {
|
||||||
|
const existing = metadataCache.get(key) as CachedMetadata;
|
||||||
|
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryBytes = estimateEntryBytes(key, entry);
|
||||||
|
|
||||||
|
while (cacheByteEstimate + entryBytes > maxBytes && metadataCache.size > 0) {
|
||||||
|
const oldest = metadataCache.keys().next().value as string;
|
||||||
|
const oldestEntry = metadataCache.get(oldest) as CachedMetadata;
|
||||||
|
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(oldest, oldestEntry);
|
||||||
|
metadataCache.delete(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataCache.set(key, entry);
|
||||||
|
cacheByteEstimate += entryBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateField(value: string | undefined): string | undefined {
|
||||||
|
if (!value)
|
||||||
|
return value;
|
||||||
|
|
||||||
|
if (value.length <= MAX_FIELD_LENGTH)
|
||||||
|
return value;
|
||||||
|
|
||||||
|
return value.slice(0, MAX_FIELD_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeImageUrl(rawUrl: string | undefined, baseUrl: string): string | undefined {
|
||||||
|
if (!rawUrl)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = new URL(rawUrl, baseUrl);
|
||||||
|
|
||||||
|
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:')
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
return resolved.href;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetaContent(html: string, patterns: RegExp[]): string | undefined {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = pattern.exec(html);
|
||||||
|
|
||||||
|
if (match?.[1])
|
||||||
|
return decodeHtmlEntities(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(///g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(html: string, url: string): CachedMetadata {
|
||||||
|
const title = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i,
|
||||||
|
/<title[^>]*>([^<]+)<\/title>/i
|
||||||
|
]);
|
||||||
|
const description = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i,
|
||||||
|
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i
|
||||||
|
]);
|
||||||
|
const rawImageUrl = getMetaContent(html, [
|
||||||
|
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
|
||||||
|
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i
|
||||||
|
]);
|
||||||
|
const siteNamePatterns = [
|
||||||
|
// eslint-disable-next-line @stylistic/js/array-element-newline
|
||||||
|
/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i
|
||||||
|
];
|
||||||
|
const siteName = getMetaContent(html, siteNamePatterns);
|
||||||
|
const imageUrl = sanitizeImageUrl(rawImageUrl, url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: truncateField(title),
|
||||||
|
description: truncateField(description),
|
||||||
|
imageUrl,
|
||||||
|
siteName: truncateField(siteName),
|
||||||
|
cachedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictExpired(): void {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
|
||||||
|
if (config.cacheTtlMinutes === 0) {
|
||||||
|
cacheByteEstimate = 0;
|
||||||
|
metadataCache.clear();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlMs = config.cacheTtlMinutes * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [key, entry] of metadataCache) {
|
||||||
|
if (now - entry.cachedAt > ttlMs) {
|
||||||
|
cacheByteEstimate -= estimateEntryBytes(key, entry);
|
||||||
|
metadataCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/link-metadata', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = getLinkPreviewConfig();
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return res.status(403).json({ error: 'Link previews are disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = String(req.query.url || '');
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostAllowed = await resolveAndValidateHost(url);
|
||||||
|
|
||||||
|
if (!hostAllowed) {
|
||||||
|
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
evictExpired();
|
||||||
|
|
||||||
|
const cached = metadataCache.get(url);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
const { cachedAt, ...metadata } = cached;
|
||||||
|
|
||||||
|
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
|
||||||
|
return res.json(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Link Metadata] Cache miss for ${url}. Fetching...`);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
const response = await safeFetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html',
|
||||||
|
'User-Agent': 'MetoYou-LinkPreview/1.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (!contentType.includes('text/html')) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
cacheSet(url, failed);
|
||||||
|
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const result = await reader.read();
|
||||||
|
|
||||||
|
done = result.done;
|
||||||
|
|
||||||
|
if (result.value) {
|
||||||
|
chunks.push(result.value);
|
||||||
|
totalBytes += result.value.length;
|
||||||
|
|
||||||
|
if (totalBytes > MAX_HTML_BYTES) {
|
||||||
|
reader.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
const metadata = parseMetadata(html, url);
|
||||||
|
|
||||||
|
cacheSet(url, metadata);
|
||||||
|
|
||||||
|
const { cachedAt, ...result } = metadata;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const url = String(req.query.url || '');
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
cacheSet(url, { failed: true, cachedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((err as { name?: string })?.name === 'AbortError') {
|
||||||
|
return res.json({ failed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Link metadata error:', err);
|
||||||
|
res.json({ failed: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,14 +11,20 @@ router.get('/image-proxy', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid URL' });
|
return res.status(400).json({ error: 'Invalid URL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostAllowed = await resolveAndValidateHost(url);
|
||||||
|
|
||||||
|
if (!hostAllowed) {
|
||||||
|
return res.status(400).json({ error: 'URL resolves to a blocked address' });
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
|
const response = await safeFetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return res.status(response.status).end();
|
return res.status(response?.status ?? 502).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|||||||
119
server/src/routes/ssrf-guard.ts
Normal file
119
server/src/routes/ssrf-guard.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { lookup } from 'dns/promises';
|
||||||
|
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
|
||||||
|
function isPrivateIp(ip: string): boolean {
|
||||||
|
if (
|
||||||
|
ip === '127.0.0.1' ||
|
||||||
|
ip === '::1' ||
|
||||||
|
ip === '0.0.0.0' ||
|
||||||
|
ip === '::'
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 10.x.x.x
|
||||||
|
if (ip.startsWith('10.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 172.16.0.0 - 172.31.255.255
|
||||||
|
if (ip.startsWith('172.')) {
|
||||||
|
const second = parseInt(ip.split('.')[1], 10);
|
||||||
|
|
||||||
|
if (second >= 16 && second <= 31)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 192.168.x.x
|
||||||
|
if (ip.startsWith('192.168.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// 169.254.x.x (link-local, AWS metadata)
|
||||||
|
if (ip.startsWith('169.254.'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// IPv6 private ranges (fc00::/7, fe80::/10)
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
|
||||||
|
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAndValidateHost(url: string): Promise<boolean> {
|
||||||
|
let hostname: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hostname = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block obvious private hostnames
|
||||||
|
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// If hostname is already an IP literal, check it directly
|
||||||
|
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
|
||||||
|
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { address } = await lookup(hostname);
|
||||||
|
|
||||||
|
return !isPrivateIp(address);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeFetchOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a URL while following redirects safely, validating each
|
||||||
|
* hop against SSRF (private/reserved IPs, blocked hostnames).
|
||||||
|
*
|
||||||
|
* The caller must validate the initial URL with `resolveAndValidateHost`
|
||||||
|
* before calling this function.
|
||||||
|
*/
|
||||||
|
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
|
||||||
|
let currentUrl = url;
|
||||||
|
let response: Response | undefined;
|
||||||
|
|
||||||
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
||||||
|
response = await fetch(currentUrl, {
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: options.signal,
|
||||||
|
headers: options.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400 && location) {
|
||||||
|
let nextUrl: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
nextUrl = new URL(location, currentUrl).href;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(nextUrl))
|
||||||
|
break;
|
||||||
|
|
||||||
|
const redirectAllowed = await resolveAndValidateHost(nextUrl);
|
||||||
|
|
||||||
|
if (!redirectAllowed)
|
||||||
|
break;
|
||||||
|
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -8,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));
|
||||||
}
|
}
|
||||||
@@ -24,6 +32,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
|
||||||
|
const usersByOderId = new Map<string, ConnectedUser>();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user) => {
|
||||||
|
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersByOderId.set(user.oderId, user);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(usersByOderId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
|
||||||
|
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
|
||||||
|
connectionId !== excludeConnectionId
|
||||||
|
&& user.oderId === oderId
|
||||||
|
&& user.serverIds.has(serverId)
|
||||||
|
&& user.ws.readyState === WebSocket.OPEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
|
||||||
|
const serverIds = new Set<string>();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user, connectionId) => {
|
||||||
|
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.serverIds.forEach((serverId) => serverIds.add(serverId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(serverIds);
|
||||||
|
}
|
||||||
|
|
||||||
export function notifyUser(oderId: string, message: WsMessage): void {
|
export function notifyUser(oderId: string, message: WsMessage): void {
|
||||||
const user = findUserByOderId(oderId);
|
const user = findUserByOderId(oderId);
|
||||||
|
|
||||||
@@ -33,5 +78,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findUserByOderId(oderId: string) {
|
export function findUserByOderId(oderId: string) {
|
||||||
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId);
|
let match: ConnectedUser | undefined;
|
||||||
|
|
||||||
|
connectedUsers.forEach((user) => {
|
||||||
|
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
|
||||||
|
match = user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
import {
|
||||||
|
broadcastToServer,
|
||||||
|
findUserByOderId,
|
||||||
|
getServerIdsForOderId,
|
||||||
|
getUniqueUsersInServer,
|
||||||
|
isOderIdConnectedToServer
|
||||||
|
} from './broadcast';
|
||||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
@@ -14,24 +20,60 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
|||||||
return normalized || fallback;
|
return normalized || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMessageId(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
if (!normalized || normalized === 'undefined' || normalized === 'null') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = Array.from(connectedUsers.values())
|
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
user.oderId = String(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 AND the same connection
|
||||||
|
// scope so offer routing always targets the freshest socket (e.g. after
|
||||||
|
// page refresh). Connections with a *different* scope (= a different
|
||||||
|
// signal URL that happens to route to this server) are left untouched so
|
||||||
|
// multi-signal-URL setups don't trigger an eviction loop.
|
||||||
|
connectedUsers.forEach((existing, existingId) => {
|
||||||
|
if (existingId !== connectionId
|
||||||
|
&& existing.oderId === newOderId
|
||||||
|
&& existing.connectionScope === newScope) {
|
||||||
|
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
existing.ws.close();
|
||||||
|
} catch { /* already closing */ }
|
||||||
|
|
||||||
|
connectedUsers.delete(existingId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const sid = String(message['serverId']);
|
const sid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
if (!sid)
|
if (!sid)
|
||||||
return;
|
return;
|
||||||
@@ -48,16 +90,20 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNew = !user.serverIds.has(sid);
|
const isNewConnectionMembership = !user.serverIds.has(sid);
|
||||||
|
const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId);
|
||||||
|
|
||||||
user.serverIds.add(sid);
|
user.serverIds.add(sid);
|
||||||
user.viewedServerId = sid;
|
user.viewedServerId = sid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
console.log(
|
||||||
|
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
|
||||||
|
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
|
||||||
|
);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
|
|
||||||
if (isNew) {
|
if (isNewIdentityMembership) {
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
@@ -68,7 +114,10 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const viewSid = String(message['serverId']);
|
const viewSid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
|
if (!viewSid)
|
||||||
|
return;
|
||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
@@ -78,7 +127,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||||
|
|
||||||
if (!leaveSid)
|
if (!leaveSid)
|
||||||
return;
|
return;
|
||||||
@@ -90,17 +139,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
|
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
|
if (remainingServerIds.includes(leaveSid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToServer(leaveSid, {
|
broadcastToServer(leaveSid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: normalizeDisplayName(user.displayName),
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: leaveSid,
|
serverId: leaveSid,
|
||||||
serverIds: Array.from(user.serverIds)
|
serverIds: remainingServerIds
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||||
const targetUserId = String(message['targetUserId'] || '');
|
const targetUserId = readMessageId(message['targetUserId']) ?? '';
|
||||||
|
|
||||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { broadcastToServer } from './broadcast';
|
import {
|
||||||
|
broadcastToServer,
|
||||||
|
getServerIdsForOderId,
|
||||||
|
isOderIdConnectedToServer
|
||||||
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
/** How often to ping all connected clients (ms). */
|
/** How often to ping all connected clients (ms). */
|
||||||
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
if (user) {
|
if (user) {
|
||||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
user.serverIds.forEach((sid) => {
|
user.serverIds.forEach((sid) => {
|
||||||
|
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
serverId: sid,
|
serverId: sid,
|
||||||
serverIds: []
|
serverIds: remainingServerIds
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
10
skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"caveman": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "4d486dd6f9fbb27ce1c51c972c9a5eb25a53236ae05eabf4d076ac1e293f4b7a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
}
|
}
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
<app-screen-share-source-picker />
|
<app-screen-share-source-picker />
|
||||||
|
<app-native-context-menu />
|
||||||
<app-debug-console [showLauncher]="false" />
|
<app-debug-console [showLauncher]="false" />
|
||||||
<app-theme-picker-overlay />
|
<app-theme-picker-overlay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/
|
|||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
|
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
SettingsModalComponent,
|
SettingsModalComponent,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent,
|
ScreenShareSourcePickerComponent,
|
||||||
|
NativeContextMenuComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent
|
ThemePickerOverlayComponent
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
|
|||||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||||
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||||
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||||
export const DEBUG_LOG_MAX_ENTRIES = 500;
|
export const DEBUG_LOG_MAX_ENTRIES = 5000;
|
||||||
export const DEFAULT_MAX_USERS = 50;
|
export const DEFAULT_MAX_USERS = 50;
|
||||||
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
||||||
export const DEFAULT_VOLUME = 100;
|
export const DEFAULT_VOLUME = 100;
|
||||||
export const SEARCH_DEBOUNCE_MS = 300;
|
export const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
export const RECONNECT_SOUND_GRACE_MS = 15_000;
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ export interface ElectronQuery {
|
|||||||
payload: unknown;
|
payload: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuParams {
|
||||||
|
posX: number;
|
||||||
|
posY: number;
|
||||||
|
isEditable: boolean;
|
||||||
|
selectionText: string;
|
||||||
|
linkURL: string;
|
||||||
|
mediaType: string;
|
||||||
|
srcURL: string;
|
||||||
|
editFlags: {
|
||||||
|
canCut: boolean;
|
||||||
|
canCopy: boolean;
|
||||||
|
canPaste: boolean;
|
||||||
|
canSelectAll: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -176,6 +192,9 @@ export interface ElectronApi {
|
|||||||
fileExists: (filePath: string) => Promise<boolean>;
|
fileExists: (filePath: string) => Promise<boolean>;
|
||||||
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;
|
||||||
|
contextMenuCommand: (command: string) => Promise<void>;
|
||||||
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,7 +302,9 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
case 'offer':
|
case 'offer':
|
||||||
case 'answer':
|
case 'answer':
|
||||||
case 'ice_candidate': {
|
case 'ice_candidate': {
|
||||||
const peerId = this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId');
|
const peerId = direction === 'outbound'
|
||||||
|
? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'))
|
||||||
|
: (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId'));
|
||||||
const displayName = this.getPayloadString(payload, 'displayName');
|
const displayName = this.getPayloadString(payload, 'displayName');
|
||||||
|
|
||||||
if (!peerId)
|
if (!peerId)
|
||||||
@@ -1295,7 +1297,7 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
private getPayloadString(payload: Record<string, unknown> | null, key: string): string | null {
|
private getPayloadString(payload: Record<string, unknown> | null, key: string): string | null {
|
||||||
const value = this.getPayloadField(payload, key);
|
const value = this.getPayloadField(payload, key);
|
||||||
|
|
||||||
return typeof value === 'string' ? value : null;
|
return this.normalizeStringValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPayloadNumber(payload: Record<string, unknown> | null, key: string): number | null {
|
private getPayloadNumber(payload: Record<string, unknown> | null, key: string): number | null {
|
||||||
@@ -1323,7 +1325,7 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
private getStringProperty(record: Record<string, unknown> | null, key: string): string | null {
|
private getStringProperty(record: Record<string, unknown> | null, key: string): string | null {
|
||||||
const value = record?.[key];
|
const value = record?.[key];
|
||||||
|
|
||||||
return typeof value === 'string' ? value : null;
|
return this.normalizeStringValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBooleanProperty(record: Record<string, unknown> | null, key: string): boolean | null {
|
private getBooleanProperty(record: Record<string, unknown> | null, key: string): boolean | null {
|
||||||
@@ -1344,4 +1346,16 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
|
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeStringValue(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string')
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
if (!normalized || normalized === 'undefined' || normalized === 'null')
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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',
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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[] {
|
||||||
@@ -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',
|
||||||
@@ -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,
|
||||||
@@ -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, ' ');
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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';
|
||||||
@@ -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' })
|
||||||
@@ -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 {
|
||||||
@@ -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 {
|
||||||
@@ -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';
|
||||||
@@ -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/') ||
|
||||||
@@ -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';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ChatAttachmentMeta } from '../../../shared-kernel';
|
import type { ChatAttachmentMeta } from '../../../../shared-kernel';
|
||||||
|
|
||||||
export type AttachmentMeta = ChatAttachmentMeta;
|
export type AttachmentMeta = ChatAttachmentMeta;
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './application/auth.service';
|
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
2
toju-app/src/app/domains/authentication/index.ts
Normal file
2
toju-app/src/app/domains/authentication/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './application/services/authentication.service';
|
||||||
|
export * from './domain/models/authentication.model';
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
|
import { LinkMetadata } from '../../../../shared-kernel';
|
||||||
|
|
||||||
|
const URL_PATTERN = /https?:\/\/[^\s<>)"']+/g;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LinkMetadataService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
extractUrls(content: string): string[] {
|
||||||
|
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||||
|
try {
|
||||||
|
const apiBase = this.serverDirectory.getApiBaseUrl();
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.get<Omit<LinkMetadata, 'url'>>(
|
||||||
|
`${apiBase}/link-metadata`,
|
||||||
|
{ params: { url } }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { url, ...result };
|
||||||
|
} catch {
|
||||||
|
return { url, failed: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllMetadata(urls: string[]): Promise<LinkMetadata[]> {
|
||||||
|
const unique = [...new Set(urls)];
|
||||||
|
|
||||||
|
return Promise.all(unique.map((url) => this.fetchMetadata(url)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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]',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
(downloadRequested)="downloadAttachment($event)"
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
(imageOpened)="openLightbox($event)"
|
(imageOpened)="openLightbox($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -29,10 +29,11 @@ import {
|
|||||||
ChatMessageComposerSubmitEvent,
|
ChatMessageComposerSubmitEvent,
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
|
ChatMessageEmbedRemoveEvent,
|
||||||
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',
|
||||||
@@ -191,6 +192,15 @@ export class ChatMessagesComponent {
|
|||||||
this.composerBottomPadding.set(height + 20);
|
this.composerBottomPadding.set(height + 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
||||||
|
this.store.dispatch(
|
||||||
|
MessagesActions.removeLinkEmbed({
|
||||||
|
messageId: event.messageId,
|
||||||
|
url: event.url
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
toggleKlipyGifPicker(): void {
|
toggleKlipyGifPicker(): void {
|
||||||
const nextState = !this.showKlipyGifPicker();
|
const nextState = !this.showKlipyGifPicker();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||||
|
@if (metadata(); as meta) {
|
||||||
|
@if (!meta.failed && (meta.title || meta.description)) {
|
||||||
|
<div class="group/embed relative mt-2 max-w-[480px] overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||||
|
@if (canRemove()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="removed.emit()"
|
||||||
|
class="absolute right-1.5 top-1.5 z-10 grid h-5 w-5 place-items-center rounded bg-background/80 text-muted-foreground opacity-0 backdrop-blur-sm transition-opacity hover:text-foreground group-hover/embed:opacity-100"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="h-3 w-3"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<div class="flex">
|
||||||
|
@if (meta.imageUrl) {
|
||||||
|
<img
|
||||||
|
[src]="meta.imageUrl"
|
||||||
|
[alt]="meta.title || 'Link preview'"
|
||||||
|
class="hidden h-auto w-28 flex-shrink-0 object-cover sm:block"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5 p-3">
|
||||||
|
@if (meta.siteName) {
|
||||||
|
<span class="truncate text-xs text-muted-foreground">{{ meta.siteName }}</span>
|
||||||
|
}
|
||||||
|
@if (meta.title) {
|
||||||
|
<a
|
||||||
|
[href]="meta.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
|
||||||
|
>{{ meta.title }}</a
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@if (meta.description) {
|
||||||
|
<span class="line-clamp-2 text-xs text-muted-foreground">{{ meta.description }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
input,
|
||||||
|
output
|
||||||
|
} from '@angular/core';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { lucideX } from '@ng-icons/lucide';
|
||||||
|
import { LinkMetadata } from '../../../../../../shared-kernel';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chat-link-embed',
|
||||||
|
standalone: true,
|
||||||
|
imports: [NgIcon],
|
||||||
|
viewProviders: [provideIcons({ lucideX })],
|
||||||
|
templateUrl: './chat-link-embed.component.html'
|
||||||
|
})
|
||||||
|
export class ChatLinkEmbedComponent {
|
||||||
|
readonly metadata = input.required<LinkMetadata>();
|
||||||
|
readonly canRemove = input(false);
|
||||||
|
readonly removed = output();
|
||||||
|
}
|
||||||
@@ -89,6 +89,16 @@
|
|||||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (msg.linkMetadata?.length) {
|
||||||
|
@for (meta of msg.linkMetadata; track meta.url) {
|
||||||
|
<app-chat-link-embed
|
||||||
|
[metadata]="meta"
|
||||||
|
[canRemove]="isOwnMessage() || isAdmin()"
|
||||||
|
(removed)="removeEmbed(meta.url)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (attachmentsList.length > 0) {
|
@if (attachmentsList.length > 0) {
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
@for (att of attachmentsList; track att.id) {
|
@for (att of attachmentsList; track att.id) {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -37,13 +37,15 @@ import {
|
|||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
} from '../../../../../../shared';
|
} from '../../../../../../shared';
|
||||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
||||||
|
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
|
||||||
import {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
|
ChatMessageEmbedRemoveEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.models';
|
} from '../../models/chat-messages.model';
|
||||||
|
|
||||||
const COMMON_EMOJIS = [
|
const COMMON_EMOJIS = [
|
||||||
'👍',
|
'👍',
|
||||||
@@ -59,6 +61,7 @@ const RICH_MARKDOWN_PATTERNS = [
|
|||||||
/(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)/m,
|
/(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)/m,
|
||||||
/!\[[^\]]*\]\([^)]+\)/,
|
/!\[[^\]]*\]\([^)]+\)/,
|
||||||
/\[[^\]]+\]\([^)]+\)/,
|
/\[[^\]]+\]\([^)]+\)/,
|
||||||
|
/https?:\/\/\S+/,
|
||||||
/`[^`\n]+`/,
|
/`[^`\n]+`/,
|
||||||
/\*\*[^*\n]+\*\*|__[^_\n]+__/,
|
/\*\*[^*\n]+\*\*|__[^_\n]+__/,
|
||||||
/\*[^*\n]+\*|_[^_\n]+_/,
|
/\*[^*\n]+\*|_[^_\n]+_/,
|
||||||
@@ -84,6 +87,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
|||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
ChatVideoPlayerComponent,
|
ChatVideoPlayerComponent,
|
||||||
ChatMessageMarkdownComponent,
|
ChatMessageMarkdownComponent,
|
||||||
|
ChatLinkEmbedComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
@@ -126,6 +130,7 @@ export class ChatMessageItemComponent {
|
|||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
readonly imageOpened = output<Attachment>();
|
readonly imageOpened = output<Attachment>();
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
|
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||||
|
|
||||||
readonly commonEmojis = COMMON_EMOJIS;
|
readonly commonEmojis = COMMON_EMOJIS;
|
||||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||||
@@ -234,6 +239,13 @@ export class ChatMessageItemComponent {
|
|||||||
this.deleteRequested.emit(this.message());
|
this.deleteRequested.emit(this.message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeEmbed(url: string): void {
|
||||||
|
this.embedRemoved.emit({
|
||||||
|
messageId: this.message().id,
|
||||||
|
url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
requestReferenceScroll(messageId: string): void {
|
requestReferenceScroll(messageId: string): void {
|
||||||
this.referenceRequested.emit(messageId);
|
this.referenceRequested.emit(messageId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,19 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
<ng-template
|
||||||
|
[remarkTemplate]="'link'"
|
||||||
|
let-node
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[href]="node.url"
|
||||||
|
[title]="node.title ?? ''"
|
||||||
|
[remarkNode]="node"
|
||||||
|
></a>
|
||||||
|
@if (isYoutubeUrl(node.url)) {
|
||||||
|
<div class="block">
|
||||||
|
<app-chat-youtube-embed [url]="node.url" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
</remark>
|
</remark>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
|
|||||||
import remarkParse from 'remark-parse';
|
import remarkParse from 'remark-parse';
|
||||||
import { unified } from 'unified';
|
import { unified } from 'unified';
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||||
|
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
|
||||||
|
|
||||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||||
cs: 'csharp',
|
cs: 'csharp',
|
||||||
@@ -38,7 +39,8 @@ const REMARK_PROCESSOR = unified()
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
RemarkModule,
|
RemarkModule,
|
||||||
MermaidComponent,
|
MermaidComponent,
|
||||||
ChatImageProxyFallbackDirective
|
ChatImageProxyFallbackDirective,
|
||||||
|
ChatYoutubeEmbedComponent
|
||||||
],
|
],
|
||||||
templateUrl: './chat-message-markdown.component.html'
|
templateUrl: './chat-message-markdown.component.html'
|
||||||
})
|
})
|
||||||
@@ -57,6 +59,10 @@ export class ChatMessageMarkdownComponent {
|
|||||||
return KLIPY_MEDIA_URL_PATTERN.test(url);
|
return KLIPY_MEDIA_URL_PATTERN.test(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isYoutubeUrl(url?: string): boolean {
|
||||||
|
return isYoutubeUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
isMermaidCodeBlock(lang?: string): boolean {
|
isMermaidCodeBlock(lang?: string): boolean {
|
||||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user