Compare commits
1 Commits
v1.0.124
...
b10004e154
| Author | SHA1 | Date | |
|---|---|---|---|
| b10004e154 |
@@ -31,14 +31,14 @@ If missing, scaffold it. See [reference/project-setup.md](./reference/project-se
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
@@ -52,17 +52,17 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 60_000, // WebRTC needs longer timeouts
|
||||
timeout: 60_000, // WebRTC needs longer timeouts
|
||||
expect: { timeout: 10_000 },
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // Sequential — shared server state
|
||||
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']
|
||||
permissions: ['microphone', 'camera'],
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
@@ -72,28 +72,28 @@ export default defineConfig({
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-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
|
||||
timeout: 30_000,
|
||||
},
|
||||
{
|
||||
command: 'cd toju-app && npx ng serve',
|
||||
port: 4200,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000
|
||||
}
|
||||
]
|
||||
timeout: 60_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
@@ -125,14 +125,14 @@ 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 |
|
||||
| ❌ 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
|
||||
|
||||
@@ -191,14 +191,14 @@ export class LoginPage {
|
||||
|
||||
**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` |
|
||||
| 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
|
||||
|
||||
@@ -206,35 +206,35 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -256,7 +256,6 @@ After generating any test:
|
||||
```
|
||||
|
||||
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
|
||||
@@ -277,7 +276,7 @@ npx playwright codegen http://localhost:4200 # Record test
|
||||
|
||||
## Reference Files
|
||||
|
||||
| File | When to Read |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| 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 |
|
||||
| [reference/project-setup.md](./reference/project-setup.md) | First-time scaffold, dependency installation, config creation |
|
||||
|
||||
@@ -17,13 +17,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Restore npm cache
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: ~/AppData/Local/npm-cache
|
||||
key: npm-windows-${{ hashFiles('package-lock.json', 'website/package-lock.json') }}
|
||||
restore-keys: npm-windows-
|
||||
|
||||
- name: Install root dependencies
|
||||
env:
|
||||
NODE_ENV: development
|
||||
|
||||
@@ -48,30 +48,18 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Restore npm cache
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: npm-linux-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
|
||||
restore-keys: npm-linux-
|
||||
|
||||
- name: Restore Electron cache
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/root/.cache/electron
|
||||
/root/.cache/electron-builder
|
||||
key: electron-linux-${{ hashFiles('package.json') }}
|
||||
restore-keys: electron-linux-
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends zip
|
||||
npm ci
|
||||
cd server && npm ci
|
||||
|
||||
- name: Install zip utility
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y zip
|
||||
|
||||
- name: Set CI release version
|
||||
run: >
|
||||
node tools/set-release-version.js
|
||||
@@ -120,22 +108,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Restore npm cache
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: ~/AppData/Local/npm-cache
|
||||
key: npm-windows-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
|
||||
restore-keys: npm-windows-
|
||||
|
||||
- name: Restore Electron cache
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/AppData/Local/electron/Cache
|
||||
~/AppData/Local/electron-builder/Cache
|
||||
key: electron-windows-${{ hashFiles('package.json') }}
|
||||
restore-keys: electron-windows-
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NODE_ENV: development
|
||||
@@ -245,6 +217,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --omit=dev
|
||||
|
||||
- name: Download previous manifest
|
||||
env:
|
||||
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
@@ -5,62 +5,40 @@ import {
|
||||
type BrowserContext,
|
||||
type Browser
|
||||
} from '@playwright/test';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
|
||||
|
||||
export interface Client {
|
||||
export type Client = {
|
||||
page: Page;
|
||||
context: BrowserContext;
|
||||
}
|
||||
};
|
||||
|
||||
interface TestServerHandle {
|
||||
port: number;
|
||||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface MultiClientFixture {
|
||||
type MultiClientFixture = {
|
||||
createClient: () => Promise<Client>;
|
||||
testServer: TestServerHandle;
|
||||
}
|
||||
|
||||
const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav');
|
||||
const CHROMIUM_FAKE_MEDIA_ARGS = [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`
|
||||
];
|
||||
const E2E_DIR = join(__dirname, '..');
|
||||
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
|
||||
browser: Browser;
|
||||
};
|
||||
|
||||
export const test = base.extend<MultiClientFixture>({
|
||||
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
|
||||
const testServer = await startTestServer();
|
||||
|
||||
await use(testServer);
|
||||
await testServer.stop();
|
||||
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 ({ testServer }, use) => {
|
||||
const browsers: Browser[] = [];
|
||||
createClient: async ({ browser }, use) => {
|
||||
const clients: Client[] = [];
|
||||
|
||||
const factory = async (): Promise<Client> => {
|
||||
// Launch a dedicated browser per client so each gets its own fake
|
||||
// audio device - shared browsers can starve the first context's
|
||||
// audio capture under load.
|
||||
const browser = await chromium.launch({ args: CHROMIUM_FAKE_MEDIA_ARGS });
|
||||
|
||||
browsers.push(browser);
|
||||
|
||||
const context = await browser.newContext({
|
||||
permissions: ['microphone', 'camera'],
|
||||
baseURL: 'http://localhost:4200'
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServer.port);
|
||||
await installTestServerEndpoint(context);
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -73,130 +51,7 @@ export const test = base.extend<MultiClientFixture>({
|
||||
for (const client of clients) {
|
||||
await client.context.close();
|
||||
}
|
||||
|
||||
for (const browser of browsers) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
const port = await allocatePort();
|
||||
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
||||
cwd: E2E_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
TEST_SERVER_PORT: String(port)
|
||||
},
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
process.stdout.write(chunk.toString());
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
process.stderr.write(chunk.toString());
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServerReady(port, child);
|
||||
} catch (error) {
|
||||
await stopServer(child);
|
||||
|
||||
if (attempt < retries) {
|
||||
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
stop: async () => {
|
||||
await stopServer(child);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('startTestServer: unreachable');
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address();
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
probe.close();
|
||||
reject(new Error('Failed to resolve an ephemeral test server port'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { port } = address;
|
||||
|
||||
probe.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
|
||||
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(readyUrl);
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Server still starting.
|
||||
}
|
||||
|
||||
await wait(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for test server on port ${port}`);
|
||||
}
|
||||
|
||||
async function stopServer(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill('SIGTERM');
|
||||
|
||||
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
|
||||
|
||||
if (!exited && child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
await once(child, 'exit');
|
||||
}
|
||||
}
|
||||
|
||||
function wait(durationMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@ 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';
|
||||
|
||||
interface SeededEndpointStorageState {
|
||||
type SeededEndpointStorageState = {
|
||||
key: string;
|
||||
removedKey: string;
|
||||
endpoints: {
|
||||
@@ -14,7 +14,7 @@ interface SeededEndpointStorageState {
|
||||
isDefault: boolean;
|
||||
status: string;
|
||||
}[];
|
||||
}
|
||||
};
|
||||
|
||||
function buildSeededEndpointStorageState(
|
||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||
@@ -40,11 +40,7 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
|
||||
const storage = window.localStorage;
|
||||
|
||||
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
||||
storage.setItem(storageState.removedKey, JSON.stringify([
|
||||
'default',
|
||||
'toju-primary',
|
||||
'toju-sweden'
|
||||
]));
|
||||
storage.setItem(storageState.removedKey, JSON.stringify(['default', 'toju-primary', 'toju-sweden']));
|
||||
} catch {
|
||||
// about:blank and some Playwright UI pages deny localStorage access.
|
||||
}
|
||||
@@ -63,7 +59,7 @@ export async function installTestServerEndpoint(
|
||||
* 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
|
||||
* relied upon — calling it in the first goto() landing page is fine since the
|
||||
* page will re-read on next navigation/reload).
|
||||
*
|
||||
* Typical usage:
|
||||
|
||||
@@ -16,7 +16,6 @@ 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');
|
||||
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
|
||||
|
||||
// ── Create isolated temp data directory ──────────────────────────────
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||
@@ -44,8 +43,8 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
|
||||
// Module resolution (require/import) uses __dirname, so server source
|
||||
// and node_modules are found from the real server/ directory.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||
'npx',
|
||||
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
|
||||
{
|
||||
cwd: tmpDir,
|
||||
env: {
|
||||
@@ -56,11 +55,10 @@ const child = spawn(
|
||||
DB_SYNCHRONIZE: 'true',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error('[E2E Server] Failed to start:', err.message);
|
||||
cleanup();
|
||||
@@ -70,10 +68,6 @@ child.on('error', (err) => {
|
||||
child.on('exit', (code) => {
|
||||
console.log(`[E2E Server] Exited with code ${code}`);
|
||||
cleanup();
|
||||
|
||||
if (shuttingDown) {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cleanup on signals ───────────────────────────────────────────────
|
||||
@@ -87,18 +81,12 @@ function cleanup() {
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
child.kill('SIGTERM');
|
||||
|
||||
// Give child 3s to exit, then force kill
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}, 3_000);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,9 @@ import { type Page } from '@playwright/test';
|
||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const connections: RTCPeerConnection[] = [];
|
||||
const syntheticMediaResources: {
|
||||
audioCtx: AudioContext;
|
||||
source?: AudioScheduledSourceNode;
|
||||
drawIntervalId?: number;
|
||||
}[] = [];
|
||||
|
||||
(window as any).__rtcConnections = connections;
|
||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||
|
||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||
|
||||
@@ -45,153 +39,6 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
|
||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
||||
|
||||
// Patch getUserMedia to use an AudioContext oscillator for audio
|
||||
// instead of the hardware capture device. Chromium's fake audio
|
||||
// device intermittently fails to produce frames after renegotiation.
|
||||
const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
||||
|
||||
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
|
||||
const wantsAudio = !!constraints?.audio;
|
||||
|
||||
if (!wantsAudio) {
|
||||
return origGetUserMedia(constraints);
|
||||
}
|
||||
|
||||
// Get the original stream (may include video)
|
||||
const originalStream = await origGetUserMedia(constraints);
|
||||
const audioCtx = new AudioContext();
|
||||
const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate);
|
||||
const noiseData = noiseBuffer.getChannelData(0);
|
||||
|
||||
for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) {
|
||||
noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18;
|
||||
}
|
||||
|
||||
const source = audioCtx.createBufferSource();
|
||||
const gain = audioCtx.createGain();
|
||||
|
||||
source.buffer = noiseBuffer;
|
||||
source.loop = true;
|
||||
gain.gain.value = 0.12;
|
||||
|
||||
const dest = audioCtx.createMediaStreamDestination();
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(dest);
|
||||
source.start();
|
||||
|
||||
if (audioCtx.state === 'suspended') {
|
||||
try {
|
||||
await audioCtx.resume();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const synthAudioTrack = dest.stream.getAudioTracks()[0];
|
||||
const resultStream = new MediaStream();
|
||||
|
||||
syntheticMediaResources.push({ audioCtx, source });
|
||||
|
||||
resultStream.addTrack(synthAudioTrack);
|
||||
|
||||
// Keep any video tracks from the original stream
|
||||
for (const videoTrack of originalStream.getVideoTracks()) {
|
||||
resultStream.addTrack(videoTrack);
|
||||
}
|
||||
|
||||
// Stop original audio tracks since we're not using them
|
||||
for (const track of originalStream.getAudioTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
|
||||
synthAudioTrack.addEventListener('ended', () => {
|
||||
try {
|
||||
source.stop();
|
||||
} catch {}
|
||||
|
||||
void audioCtx.close().catch(() => {});
|
||||
}, { once: true });
|
||||
|
||||
return resultStream;
|
||||
};
|
||||
|
||||
// Patch getDisplayMedia to return a synthetic screen share stream
|
||||
// (canvas-based video + 880Hz oscillator audio) so the browser
|
||||
// picker dialog is never shown.
|
||||
navigator.mediaDevices.getDisplayMedia = async (_constraints?: DisplayMediaStreamOptions) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context unavailable');
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
// Draw animated frames so video stats show increasing bytes
|
||||
const drawFrame = () => {
|
||||
frameCount++;
|
||||
ctx.fillStyle = `hsl(${frameCount % 360}, 70%, 50%)`;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '24px monospace';
|
||||
ctx.fillText(`Screen Share Frame ${frameCount}`, 40, 60);
|
||||
};
|
||||
|
||||
drawFrame();
|
||||
const drawInterval = setInterval(drawFrame, 100);
|
||||
const videoStream = canvas.captureStream(10); // 10 fps
|
||||
const videoTrack = videoStream.getVideoTracks()[0];
|
||||
|
||||
// Stop drawing when the track ends
|
||||
videoTrack.addEventListener('ended', () => clearInterval(drawInterval));
|
||||
|
||||
// Create 880Hz oscillator for screen share audio (distinct from 440Hz voice)
|
||||
const audioCtx = new AudioContext();
|
||||
const osc = audioCtx.createOscillator();
|
||||
|
||||
osc.frequency.value = 880;
|
||||
|
||||
const dest = audioCtx.createMediaStreamDestination();
|
||||
|
||||
osc.connect(dest);
|
||||
osc.start();
|
||||
|
||||
if (audioCtx.state === 'suspended') {
|
||||
try {
|
||||
await audioCtx.resume();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const audioTrack = dest.stream.getAudioTracks()[0];
|
||||
// Combine video + audio into one stream
|
||||
const resultStream = new MediaStream([videoTrack, audioTrack]);
|
||||
|
||||
syntheticMediaResources.push({
|
||||
audioCtx,
|
||||
source: osc,
|
||||
drawIntervalId: drawInterval as unknown as number
|
||||
});
|
||||
|
||||
audioTrack.addEventListener('ended', () => {
|
||||
clearInterval(drawInterval);
|
||||
|
||||
try {
|
||||
osc.stop();
|
||||
} catch {}
|
||||
|
||||
void audioCtx.close().catch(() => {});
|
||||
}, { once: true });
|
||||
|
||||
// Tag the stream so tests can identify it
|
||||
(resultStream as any).__isScreenShare = true;
|
||||
|
||||
return resultStream;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,10 +66,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbound and inbound audio RTP stats aggregated across all peer
|
||||
* connections. Uses a per-connection high water mark stored on `window` so
|
||||
* that connections that close mid-measurement still contribute their last
|
||||
* known counters, preventing the aggregate from going backwards.
|
||||
* 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;
|
||||
@@ -234,90 +78,38 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
|
||||
interface HWMEntry {
|
||||
outBytesSent: number;
|
||||
outPacketsSent: number;
|
||||
inBytesReceived: number;
|
||||
inPacketsReceived: number;
|
||||
hasOutbound: boolean;
|
||||
hasInbound: boolean;
|
||||
};
|
||||
let outbound: { bytesSent: number; packetsSent: number } | null = null;
|
||||
let inbound: { bytesReceived: number; packetsReceived: number } | null = null;
|
||||
|
||||
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM =
|
||||
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||
for (const pc of connections) {
|
||||
if (pc.connectionState !== 'connected')
|
||||
continue;
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
|
||||
try {
|
||||
stats = await connections[idx].getStats();
|
||||
} catch {
|
||||
continue; // closed connection - keep its last HWM
|
||||
}
|
||||
|
||||
let obytes = 0;
|
||||
let opackets = 0;
|
||||
let ibytes = 0;
|
||||
let ipackets = 0;
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
const stats = await pc.getStats();
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
const reportMediaType = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||
hasOut = true;
|
||||
obytes += report.bytesSent ?? 0;
|
||||
opackets += report.packetsSent ?? 0;
|
||||
if (report.type === 'outbound-rtp' && reportMediaType === 'audio' && !outbound) {
|
||||
outbound = {
|
||||
bytesSent: report.bytesSent ?? 0,
|
||||
packetsSent: report.packetsSent ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
if (report.type === 'inbound-rtp' && kind === 'audio') {
|
||||
hasIn = true;
|
||||
ibytes += report.bytesReceived ?? 0;
|
||||
ipackets += report.packetsReceived ?? 0;
|
||||
if (report.type === 'inbound-rtp' && reportMediaType === 'audio' && !inbound) {
|
||||
inbound = {
|
||||
bytesReceived: report.bytesReceived ?? 0,
|
||||
packetsReceived: report.packetsReceived ?? 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (hasOut || hasIn) {
|
||||
hwm[idx] = {
|
||||
outBytesSent: obytes,
|
||||
outPacketsSent: opackets,
|
||||
inBytesReceived: ibytes,
|
||||
inPacketsReceived: ipackets,
|
||||
hasOutbound: hasOut,
|
||||
hasInbound: hasIn
|
||||
};
|
||||
}
|
||||
if (outbound && inbound)
|
||||
break;
|
||||
}
|
||||
|
||||
let totalOutBytes = 0;
|
||||
let totalOutPackets = 0;
|
||||
let totalInBytes = 0;
|
||||
let totalInPackets = 0;
|
||||
let anyOutbound = false;
|
||||
let anyInbound = false;
|
||||
|
||||
for (const entry of Object.values(hwm)) {
|
||||
totalOutBytes += entry.outBytesSent;
|
||||
totalOutPackets += entry.outPacketsSent;
|
||||
totalInBytes += entry.inBytesReceived;
|
||||
totalInPackets += entry.inPacketsReceived;
|
||||
|
||||
if (entry.hasOutbound)
|
||||
anyOutbound = true;
|
||||
|
||||
if (entry.hasInbound)
|
||||
anyInbound = true;
|
||||
}
|
||||
|
||||
return {
|
||||
outbound: anyOutbound
|
||||
? { bytesSent: totalOutBytes, packetsSent: totalOutPackets }
|
||||
: null,
|
||||
inbound: anyInbound
|
||||
? { bytesReceived: totalInBytes, packetsReceived: totalInPackets }
|
||||
: null
|
||||
};
|
||||
return { outbound, inbound };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -328,8 +120,6 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
|
||||
outboundBytesDelta: number;
|
||||
inboundBytesDelta: number;
|
||||
outboundPacketsDelta: number;
|
||||
inboundPacketsDelta: number;
|
||||
}> {
|
||||
const before = await getAudioStats(page);
|
||||
|
||||
@@ -339,434 +129,6 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
||||
|
||||
return {
|
||||
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
|
||||
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0),
|
||||
outboundPacketsDelta: (after.outbound?.packetsSent ?? 0) - (before.outbound?.packetsSent ?? 0),
|
||||
inboundPacketsDelta: (after.inbound?.packetsReceived ?? 0) - (before.inbound?.packetsReceived ?? 0)
|
||||
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until at least one connection has both outbound-rtp and inbound-rtp
|
||||
* audio reports. Call after `waitForPeerConnected` to ensure the audio
|
||||
* pipeline is ready before measuring deltas.
|
||||
*/
|
||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
|
||||
for (const pc of connections) {
|
||||
let stats: RTCStatsReport;
|
||||
|
||||
try {
|
||||
stats = await pc.getStats();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'audio')
|
||||
hasOut = true;
|
||||
|
||||
if (report.type === 'inbound-rtp' && kind === 'audio')
|
||||
hasIn = true;
|
||||
});
|
||||
|
||||
if (hasOut && hasIn)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
interface AudioFlowDelta {
|
||||
outboundBytesDelta: number;
|
||||
inboundBytesDelta: number;
|
||||
outboundPacketsDelta: number;
|
||||
inboundPacketsDelta: number;
|
||||
}
|
||||
|
||||
function snapshotToDelta(
|
||||
curr: Awaited<ReturnType<typeof getAudioStats>>,
|
||||
prev: Awaited<ReturnType<typeof getAudioStats>>
|
||||
): AudioFlowDelta {
|
||||
return {
|
||||
outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0),
|
||||
inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0),
|
||||
outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0),
|
||||
inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
function isDeltaFlowing(delta: AudioFlowDelta): boolean {
|
||||
const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
|
||||
const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
|
||||
|
||||
return outFlowing && inFlowing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until two consecutive HWM-based reads show both outbound and inbound
|
||||
* audio byte counts increasing. Combines per-connection high-water marks
|
||||
* (which prevent totals from going backwards after connection churn) with
|
||||
* consecutive comparison (which avoids a stale single baseline).
|
||||
*/
|
||||
export async function waitForAudioFlow(
|
||||
page: Page,
|
||||
timeoutMs = 30_000,
|
||||
pollIntervalMs = 1_000
|
||||
): Promise<AudioFlowDelta> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
let prev = await getAudioStats(page);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
const curr = await getAudioStats(page);
|
||||
const delta = snapshotToDelta(curr, prev);
|
||||
|
||||
if (isDeltaFlowing(delta)) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
// Timeout - return zero deltas so the caller's assertion reports the failure.
|
||||
return {
|
||||
outboundBytesDelta: 0,
|
||||
inboundBytesDelta: 0,
|
||||
outboundPacketsDelta: 0,
|
||||
inboundPacketsDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get outbound and inbound video RTP stats aggregated across all peer
|
||||
* connections. Uses the same HWM pattern as {@link getAudioStats}.
|
||||
*/
|
||||
export async function getVideoStats(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 };
|
||||
|
||||
interface VHWM {
|
||||
outBytesSent: number;
|
||||
outPacketsSent: number;
|
||||
inBytesReceived: number;
|
||||
inPacketsReceived: number;
|
||||
hasOutbound: boolean;
|
||||
hasInbound: boolean;
|
||||
}
|
||||
|
||||
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM =
|
||||
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
|
||||
try {
|
||||
stats = await connections[idx].getStats();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let obytes = 0;
|
||||
let opackets = 0;
|
||||
let ibytes = 0;
|
||||
let ipackets = 0;
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'video') {
|
||||
hasOut = true;
|
||||
obytes += report.bytesSent ?? 0;
|
||||
opackets += report.packetsSent ?? 0;
|
||||
}
|
||||
|
||||
if (report.type === 'inbound-rtp' && kind === 'video') {
|
||||
hasIn = true;
|
||||
ibytes += report.bytesReceived ?? 0;
|
||||
ipackets += report.packetsReceived ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasOut || hasIn) {
|
||||
hwm[idx] = {
|
||||
outBytesSent: obytes,
|
||||
outPacketsSent: opackets,
|
||||
inBytesReceived: ibytes,
|
||||
inPacketsReceived: ipackets,
|
||||
hasOutbound: hasOut,
|
||||
hasInbound: hasIn
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let totalOutBytes = 0;
|
||||
let totalOutPackets = 0;
|
||||
let totalInBytes = 0;
|
||||
let totalInPackets = 0;
|
||||
let anyOutbound = false;
|
||||
let anyInbound = false;
|
||||
|
||||
for (const entry of Object.values(hwm)) {
|
||||
totalOutBytes += entry.outBytesSent;
|
||||
totalOutPackets += entry.outPacketsSent;
|
||||
totalInBytes += entry.inBytesReceived;
|
||||
totalInPackets += entry.inPacketsReceived;
|
||||
|
||||
if (entry.hasOutbound)
|
||||
anyOutbound = true;
|
||||
|
||||
if (entry.hasInbound)
|
||||
anyInbound = true;
|
||||
}
|
||||
|
||||
return {
|
||||
outbound: anyOutbound
|
||||
? { bytesSent: totalOutBytes, packetsSent: totalOutPackets }
|
||||
: null,
|
||||
inbound: anyInbound
|
||||
? { bytesReceived: totalInBytes, packetsReceived: totalInPackets }
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until at least one connection has both outbound-rtp and inbound-rtp
|
||||
* video reports.
|
||||
*/
|
||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
|
||||
for (const pc of connections) {
|
||||
let stats: RTCStatsReport;
|
||||
|
||||
try {
|
||||
stats = await pc.getStats();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasOut = false;
|
||||
let hasIn = false;
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
|
||||
if (report.type === 'outbound-rtp' && kind === 'video')
|
||||
hasOut = true;
|
||||
|
||||
if (report.type === 'inbound-rtp' && kind === 'video')
|
||||
hasIn = true;
|
||||
});
|
||||
|
||||
if (hasOut && hasIn)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoFlowDelta {
|
||||
outboundBytesDelta: number;
|
||||
inboundBytesDelta: number;
|
||||
outboundPacketsDelta: number;
|
||||
inboundPacketsDelta: number;
|
||||
}
|
||||
|
||||
function videoSnapshotToDelta(
|
||||
curr: Awaited<ReturnType<typeof getVideoStats>>,
|
||||
prev: Awaited<ReturnType<typeof getVideoStats>>
|
||||
): VideoFlowDelta {
|
||||
return {
|
||||
outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0),
|
||||
inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0),
|
||||
outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0),
|
||||
inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
function isVideoDeltaFlowing(delta: VideoFlowDelta): boolean {
|
||||
const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
|
||||
const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
|
||||
|
||||
return outFlowing && inFlowing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until two consecutive HWM-based reads show both outbound and inbound
|
||||
* video byte counts increasing - proving screen share video is flowing.
|
||||
*/
|
||||
export async function waitForVideoFlow(
|
||||
page: Page,
|
||||
timeoutMs = 30_000,
|
||||
pollIntervalMs = 1_000
|
||||
): Promise<VideoFlowDelta> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
let prev = await getVideoStats(page);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
const curr = await getVideoStats(page);
|
||||
const delta = videoSnapshotToDelta(curr, prev);
|
||||
|
||||
if (isVideoDeltaFlowing(delta)) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
return {
|
||||
outboundBytesDelta: 0,
|
||||
inboundBytesDelta: 0,
|
||||
outboundPacketsDelta: 0,
|
||||
inboundPacketsDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until outbound video bytes are increasing (sender side).
|
||||
* Use on the page that is sharing its screen.
|
||||
*/
|
||||
export async function waitForOutboundVideoFlow(
|
||||
page: Page,
|
||||
timeoutMs = 30_000,
|
||||
pollIntervalMs = 1_000
|
||||
): Promise<VideoFlowDelta> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
let prev = await getVideoStats(page);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
const curr = await getVideoStats(page);
|
||||
const delta = videoSnapshotToDelta(curr, prev);
|
||||
|
||||
if (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
return {
|
||||
outboundBytesDelta: 0,
|
||||
inboundBytesDelta: 0,
|
||||
outboundPacketsDelta: 0,
|
||||
inboundPacketsDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until inbound video bytes are increasing (receiver side).
|
||||
* Use on the page that is viewing someone else's screen share.
|
||||
*/
|
||||
export async function waitForInboundVideoFlow(
|
||||
page: Page,
|
||||
timeoutMs = 30_000,
|
||||
pollIntervalMs = 1_000
|
||||
): Promise<VideoFlowDelta> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
let prev = await getVideoStats(page);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
const curr = await getVideoStats(page);
|
||||
const delta = videoSnapshotToDelta(curr, prev);
|
||||
|
||||
if (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
return {
|
||||
outboundBytesDelta: 0,
|
||||
inboundBytesDelta: 0,
|
||||
outboundPacketsDelta: 0,
|
||||
inboundPacketsDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump full RTC connection diagnostics for debugging audio flow failures.
|
||||
*/
|
||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!conns?.length)
|
||||
return 'No connections tracked';
|
||||
|
||||
const lines: string[] = [`Total connections: ${conns.length}`];
|
||||
|
||||
for (let idx = 0; idx < conns.length; idx++) {
|
||||
const pc = conns[idx];
|
||||
|
||||
lines.push(`PC[${idx}]: connection=${pc.connectionState}, signaling=${pc.signalingState}`);
|
||||
|
||||
const senders = pc.getSenders().map(
|
||||
(sender) => `${sender.track?.kind ?? 'none'}:enabled=${sender.track?.enabled}:${sender.track?.readyState ?? 'null'}`
|
||||
);
|
||||
const receivers = pc.getReceivers().map(
|
||||
(recv) => `${recv.track?.kind ?? 'none'}:enabled=${recv.track?.enabled}:${recv.track?.readyState ?? 'null'}`
|
||||
);
|
||||
|
||||
lines.push(` senders=[${senders.join(', ')}]`);
|
||||
lines.push(` receivers=[${receivers.join(', ')}]`);
|
||||
|
||||
try {
|
||||
const stats = await pc.getStats();
|
||||
|
||||
stats.forEach((report: any) => {
|
||||
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
|
||||
return;
|
||||
|
||||
const kind = report.kind ?? report.mediaType;
|
||||
const bytes = report.type === 'outbound-rtp' ? report.bytesSent : report.bytesReceived;
|
||||
const packets = report.type === 'outbound-rtp' ? report.packetsSent : report.packetsReceived;
|
||||
|
||||
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
|
||||
});
|
||||
} catch (err: any) {
|
||||
lines.push(` getStats() failed: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
|
||||
export interface ChatDropFilePayload {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
export class ChatMessagesPage {
|
||||
readonly composer: Locator;
|
||||
readonly composerInput: Locator;
|
||||
readonly sendButton: Locator;
|
||||
readonly typingIndicator: Locator;
|
||||
readonly gifButton: Locator;
|
||||
readonly gifPicker: Locator;
|
||||
readonly messageItems: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.composer = page.locator('app-chat-message-composer');
|
||||
this.composerInput = page.getByPlaceholder('Type a message...');
|
||||
this.sendButton = page.getByRole('button', { name: 'Send message' });
|
||||
this.typingIndicator = page.locator('app-typing-indicator');
|
||||
this.gifButton = page.getByRole('button', { name: 'Search KLIPY GIFs' });
|
||||
this.gifPicker = page.getByRole('dialog', { name: 'KLIPY GIF picker' });
|
||||
this.messageItems = page.locator('[data-message-id]');
|
||||
}
|
||||
|
||||
async waitForReady(): Promise<void> {
|
||||
await expect(this.composerInput).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async sendMessage(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
await this.sendButton.click();
|
||||
}
|
||||
|
||||
async typeDraft(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
}
|
||||
|
||||
async clearDraft(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill('');
|
||||
}
|
||||
|
||||
async attachFiles(files: ChatDropFilePayload[]): Promise<void> {
|
||||
await this.waitForReady();
|
||||
|
||||
await this.composerInput.evaluate((element, payloads: ChatDropFilePayload[]) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
for (const payload of payloads) {
|
||||
const binary = atob(payload.base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
dataTransfer.items.add(new File([bytes], payload.name, { type: payload.mimeType }));
|
||||
}
|
||||
|
||||
element.dispatchEvent(new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer
|
||||
}));
|
||||
}, files);
|
||||
}
|
||||
|
||||
async openGifPicker(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.gifButton.click();
|
||||
await expect(this.gifPicker).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async selectFirstGif(): Promise<void> {
|
||||
const gifCard = this.gifPicker.getByRole('button', { name: /click to select/i }).first();
|
||||
|
||||
await expect(gifCard).toBeVisible({ timeout: 10_000 });
|
||||
await gifCard.click();
|
||||
}
|
||||
|
||||
getMessageItemByText(text: string): Locator {
|
||||
return this.messageItems.filter({
|
||||
has: this.page.getByText(text, { exact: false })
|
||||
}).last();
|
||||
}
|
||||
|
||||
getMessageImageByAlt(altText: string): Locator {
|
||||
return this.page.locator(`[data-message-id] img[alt="${altText}"]`).last();
|
||||
}
|
||||
|
||||
async expectMessageImageLoaded(altText: string): Promise<void> {
|
||||
const image = this.getMessageImageByAlt(altText);
|
||||
|
||||
await expect(image).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(async () =>
|
||||
image.evaluate((element) => {
|
||||
const img = element as HTMLImageElement;
|
||||
|
||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||
}), {
|
||||
timeout: 20_000,
|
||||
message: `Image ${altText} should fully load in chat`
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
getEmbedCardByTitle(title: string): Locator {
|
||||
return this.page.locator('app-chat-link-embed').filter({
|
||||
has: this.page.getByText(title, { exact: true })
|
||||
})
|
||||
.last();
|
||||
}
|
||||
|
||||
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
|
||||
const messageItem = this.getMessageItemByText(originalText);
|
||||
const editButton = messageItem.locator('button:has(ng-icon[name="lucideEdit"])').first();
|
||||
const editTextarea = this.page.locator('textarea.edit-textarea').first();
|
||||
const saveButton = this.page.locator('button:has(ng-icon[name="lucideCheck"])').first();
|
||||
|
||||
await expect(messageItem).toBeVisible({ timeout: 15_000 });
|
||||
await messageItem.hover();
|
||||
await editButton.click();
|
||||
await expect(editTextarea).toBeVisible({ timeout: 10_000 });
|
||||
await editTextarea.fill(updatedText);
|
||||
await saveButton.click();
|
||||
}
|
||||
|
||||
async deleteOwnMessage(text: string): Promise<void> {
|
||||
const messageItem = this.getMessageItemByText(text);
|
||||
const deleteButton = messageItem.locator('button:has(ng-icon[name="lucideTrash2"])').first();
|
||||
|
||||
await expect(messageItem).toBeVisible({ timeout: 15_000 });
|
||||
await messageItem.hover();
|
||||
await deleteButton.click();
|
||||
}
|
||||
}
|
||||
@@ -26,39 +26,6 @@ export class ChatRoomPage {
|
||||
await channelButton.click();
|
||||
}
|
||||
|
||||
/** Click a text channel by name in the channels sidebar to switch chat rooms. */
|
||||
async joinTextChannel(channelName: string) {
|
||||
const channelButton = this.getTextChannelButton(channelName);
|
||||
|
||||
if (await channelButton.count() === 0) {
|
||||
await this.refreshRoomMetadata();
|
||||
}
|
||||
|
||||
await expect(channelButton).toBeVisible({ timeout: 15_000 });
|
||||
await channelButton.click();
|
||||
}
|
||||
|
||||
/** Creates a text channel and waits until it appears locally. */
|
||||
async ensureTextChannelExists(channelName: string) {
|
||||
const channelButton = this.getTextChannelButton(channelName);
|
||||
|
||||
if (await channelButton.count() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.openCreateTextChannelDialog();
|
||||
await this.createChannel(channelName);
|
||||
|
||||
try {
|
||||
await expect(channelButton).toBeVisible({ timeout: 5_000 });
|
||||
} catch {
|
||||
await this.createTextChannelThroughComponent(channelName);
|
||||
}
|
||||
|
||||
await this.persistCurrentChannelsToServer(channelName);
|
||||
await expect(channelButton).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Click "Create Voice Channel" button in the channels sidebar. */
|
||||
async openCreateVoiceChannelDialog() {
|
||||
await this.page.locator('button[title="Create Voice Channel"]').click();
|
||||
@@ -77,17 +44,7 @@ export class ChatRoomPage {
|
||||
|
||||
await expect(channelNameInput).toBeVisible({ timeout: 10_000 });
|
||||
await channelNameInput.fill(name);
|
||||
await channelNameInput.press('Enter');
|
||||
|
||||
if (await dialog.isVisible()) {
|
||||
try {
|
||||
await createButton.click();
|
||||
} catch {
|
||||
// Enter may already have confirmed and removed the dialog.
|
||||
}
|
||||
}
|
||||
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
/** Get the voice controls component. */
|
||||
@@ -119,272 +76,4 @@ export class ChatRoomPage {
|
||||
|
||||
return userAvatars.count();
|
||||
}
|
||||
|
||||
/** Get the screen share toggle button inside voice controls. */
|
||||
get screenShareButton() {
|
||||
return this.voiceControls.locator(
|
||||
'button:has(ng-icon[name="lucideMonitor"]), button:has(ng-icon[name="lucideMonitorOff"])'
|
||||
).first();
|
||||
}
|
||||
|
||||
/** Start screen sharing. Bypasses the quality dialog via localStorage preset. */
|
||||
async startScreenShare() {
|
||||
// Disable quality dialog so clicking the button starts sharing immediately
|
||||
await this.page.evaluate(() => {
|
||||
const key = 'metoyou_voice_settings';
|
||||
const raw = localStorage.getItem(key);
|
||||
const settings = raw ? JSON.parse(raw) : {};
|
||||
|
||||
settings.askScreenShareQuality = false;
|
||||
settings.screenShareQuality = 'balanced';
|
||||
localStorage.setItem(key, JSON.stringify(settings));
|
||||
});
|
||||
|
||||
await this.screenShareButton.click();
|
||||
}
|
||||
|
||||
/** Stop screen sharing by clicking the active screen share button. */
|
||||
async stopScreenShare() {
|
||||
await this.screenShareButton.click();
|
||||
}
|
||||
|
||||
/** Check whether the screen share button shows the active (MonitorOff) icon. */
|
||||
get isScreenShareActive() {
|
||||
return this.voiceControls.locator('button:has(ng-icon[name="lucideMonitorOff"])').first();
|
||||
}
|
||||
|
||||
private getTextChannelButton(channelName: string): Locator {
|
||||
const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i');
|
||||
|
||||
return this.channelsSidePanel.getByRole('button', { name: channelPattern }).first();
|
||||
}
|
||||
|
||||
private async createTextChannelThroughComponent(channelName: string): Promise<void> {
|
||||
await this.page.evaluate((name) => {
|
||||
interface ChannelSidebarComponent {
|
||||
createChannel: (type: 'text' | 'voice') => void;
|
||||
newChannelName: string;
|
||||
confirmCreateChannel: () => void;
|
||||
}
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => ChannelSidebarComponent;
|
||||
}
|
||||
interface WindowWithAngularDebug extends Window {
|
||||
ng?: AngularDebugApi;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as WindowWithAngularDebug).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
throw new Error('Angular debug API unavailable for text channel fallback');
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
|
||||
component.createChannel('text');
|
||||
component.newChannelName = name;
|
||||
component.confirmCreateChannel();
|
||||
}, channelName);
|
||||
}
|
||||
|
||||
private async persistCurrentChannelsToServer(channelName: string): Promise<void> {
|
||||
const result = await this.page.evaluate(async (requestedChannelName) => {
|
||||
interface ServerEndpoint {
|
||||
isActive?: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ChannelShape {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface RoomShape {
|
||||
id: string;
|
||||
sourceUrl?: string;
|
||||
channels?: ChannelShape[];
|
||||
}
|
||||
|
||||
interface UserShape {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ChannelSidebarComponent {
|
||||
currentRoom: () => RoomShape | null;
|
||||
currentUser: () => UserShape | null;
|
||||
}
|
||||
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => ChannelSidebarComponent;
|
||||
}
|
||||
|
||||
interface WindowWithAngularDebug extends Window {
|
||||
ng?: AngularDebugApi;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as WindowWithAngularDebug).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
throw new Error('Angular debug API unavailable for channel persistence');
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const room = component.currentRoom();
|
||||
const currentUser = component.currentUser();
|
||||
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[];
|
||||
const activeEndpoint = endpoints.find((endpoint) => endpoint.isActive) || endpoints[0] || null;
|
||||
const apiBaseUrl = room?.sourceUrl || activeEndpoint?.url;
|
||||
const normalizedChannelName = requestedChannelName.trim().replace(/\s+/g, ' ');
|
||||
const existingChannels = Array.isArray(room?.channels) ? room.channels : [];
|
||||
const hasTextChannel = existingChannels.some((channel) =>
|
||||
channel.type === 'text' && channel.name.trim().toLowerCase() === normalizedChannelName.toLowerCase()
|
||||
);
|
||||
const nextChannels = hasTextChannel
|
||||
? existingChannels
|
||||
: [
|
||||
...existingChannels,
|
||||
{
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
name: normalizedChannelName,
|
||||
type: 'text' as const,
|
||||
position: existingChannels.length
|
||||
}
|
||||
];
|
||||
|
||||
if (!room?.id || !currentUser?.id || !apiBaseUrl) {
|
||||
throw new Error('Missing room, user, or endpoint when persisting channels');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentOwnerId: currentUser.id,
|
||||
channels: nextChannels
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to persist channels: ${response.status}`);
|
||||
}
|
||||
|
||||
return { roomId: room.id, channels: nextChannels };
|
||||
}, channelName);
|
||||
|
||||
// Update NGRX store directly so the UI reflects the new channel
|
||||
// immediately, without waiting for an async effect round-trip.
|
||||
await this.dispatchRoomChannelsUpdate(result.roomId, result.channels);
|
||||
}
|
||||
|
||||
private async dispatchRoomChannelsUpdate(
|
||||
roomId: string,
|
||||
channels: { id: string; name: string; type: string; position: number }[]
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(({ rid, chs }) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const store = component['store'] as { dispatch: (a: Record<string, unknown>) => void } | undefined;
|
||||
|
||||
if (store?.dispatch) {
|
||||
store.dispatch({
|
||||
type: '[Rooms] Update Room',
|
||||
roomId: rid,
|
||||
changes: { channels: chs }
|
||||
});
|
||||
}
|
||||
}, { rid: roomId, chs: channels });
|
||||
}
|
||||
|
||||
private async refreshRoomMetadata(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
interface ServerEndpoint {
|
||||
isActive?: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ChannelShape {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WindowWithAngularDebug extends Window {
|
||||
ng?: AngularDebugApi;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as WindowWithAngularDebug).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
throw new Error('Angular debug API unavailable for room refresh');
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = typeof component['currentRoom'] === 'function'
|
||||
? (component['currentRoom'] as () => { id: string; sourceUrl?: string; channels?: ChannelShape[] } | null)()
|
||||
: null;
|
||||
|
||||
if (!currentRoom) {
|
||||
throw new Error('No current room to refresh');
|
||||
}
|
||||
|
||||
const store = component['store'] as { dispatch: (action: Record<string, unknown>) => void } | undefined;
|
||||
|
||||
if (!store?.dispatch) {
|
||||
throw new Error('NGRX store not available on component');
|
||||
}
|
||||
|
||||
// Fetch server data directly via REST API instead of triggering
|
||||
// an async NGRX effect that can race with pending writes.
|
||||
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[];
|
||||
const activeEndpoint = endpoints.find((ep) => ep.isActive) || endpoints[0] || null;
|
||||
const apiBaseUrl = currentRoom.sourceUrl || activeEndpoint?.url;
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error('No API base URL available for room refresh');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/servers/${currentRoom.id}`);
|
||||
|
||||
if (response.ok) {
|
||||
const serverData = await response.json() as { channels?: ChannelShape[] };
|
||||
|
||||
if (serverData.channels?.length) {
|
||||
store.dispatch({
|
||||
type: '[Rooms] Update Room',
|
||||
roomId: currentRoom.id,
|
||||
changes: { channels: serverData.channels }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Brief wait for Angular change detection to propagate
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
expect,
|
||||
type Page,
|
||||
type Locator
|
||||
} from '@playwright/test';
|
||||
import { expect, type Page, type Locator } from '@playwright/test';
|
||||
|
||||
export class RegisterPage {
|
||||
readonly usernameInput: Locator;
|
||||
@@ -26,18 +22,7 @@ export class RegisterPage {
|
||||
async goto() {
|
||||
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
try {
|
||||
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
} catch {
|
||||
// Angular router may redirect to /login on first load; use the
|
||||
// visible login-form action instead of broad text matching.
|
||||
const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last();
|
||||
|
||||
await expect(registerButton).toBeVisible({ timeout: 10_000 });
|
||||
await registerButton.click();
|
||||
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
|
||||
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
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', open: 'never' }], ['list']],
|
||||
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
|
||||
actionTimeout: 15_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
@@ -22,15 +24,29 @@ export default defineConfig({
|
||||
...devices['Desktop Chrome'],
|
||||
permissions: ['microphone', 'camera'],
|
||||
launchOptions: {
|
||||
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
|
||||
}
|
||||
}
|
||||
}
|
||||
args: [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
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,
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'cd ../toju-app && npx ng serve',
|
||||
url: 'http://localhost:4200',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
type Client
|
||||
} from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||
|
||||
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
|
||||
const MOCK_EMBED_TITLE = 'Mock Embed Title';
|
||||
const MOCK_EMBED_DESCRIPTION = 'Mock embed description for chat E2E coverage.';
|
||||
const MOCK_GIF_IMAGE_URL = 'data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
|
||||
test.describe('Chat messaging features', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('syncs messages in a newly created text channel', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const channelName = uniqueName('updates');
|
||||
const aliceMessage = `Alice text channel message ${uniqueName('msg')}`;
|
||||
const bobMessage = `Bob text channel reply ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Alice creates a new text channel and both users join it', async () => {
|
||||
await scenario.aliceRoom.ensureTextChannelExists(channelName);
|
||||
await scenario.aliceRoom.joinTextChannel(channelName);
|
||||
await scenario.bobRoom.joinTextChannel(channelName);
|
||||
});
|
||||
|
||||
await test.step('Alice and Bob see synced messages in the new text channel', async () => {
|
||||
await scenario.aliceMessages.sendMessage(aliceMessage);
|
||||
await expect(scenario.bobMessages.getMessageItemByText(aliceMessage)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await scenario.bobMessages.sendMessage(bobMessage);
|
||||
await expect(scenario.aliceMessages.getMessageItemByText(bobMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('shows typing indicators to other users', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const draftMessage = `Typing indicator draft ${uniqueName('draft')}`;
|
||||
|
||||
await test.step('Alice starts typing in general channel', async () => {
|
||||
await scenario.aliceMessages.typeDraft(draftMessage);
|
||||
});
|
||||
|
||||
await test.step('Bob sees Alice typing', async () => {
|
||||
await expect(scenario.bob.page.getByText('Alice is typing...')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('edits and removes messages for both users', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const originalMessage = `Editable message ${uniqueName('edit')}`;
|
||||
const updatedMessage = `Edited message ${uniqueName('edit')}`;
|
||||
|
||||
await test.step('Alice sends a message and Bob receives it', async () => {
|
||||
await scenario.aliceMessages.sendMessage(originalMessage);
|
||||
await expect(scenario.bobMessages.getMessageItemByText(originalMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice edits the message and both users see updated content', async () => {
|
||||
await scenario.aliceMessages.editOwnMessage(originalMessage, updatedMessage);
|
||||
await expect(scenario.aliceMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.alice.page.getByText('(edited)')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(scenario.bobMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice deletes the message and both users see deletion state', async () => {
|
||||
await scenario.aliceMessages.deleteOwnMessage(updatedMessage);
|
||||
await expect(scenario.aliceMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('syncs image and file attachments between users', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const imageName = `${uniqueName('diagram')}.svg`;
|
||||
const fileName = `${uniqueName('notes')}.txt`;
|
||||
const imageCaption = `Image upload ${uniqueName('caption')}`;
|
||||
const fileCaption = `File upload ${uniqueName('caption')}`;
|
||||
const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName));
|
||||
const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`);
|
||||
|
||||
await test.step('Alice sends image attachment and Bob receives it', async () => {
|
||||
await scenario.aliceMessages.attachFiles([imageAttachment]);
|
||||
await scenario.aliceMessages.sendMessage(imageCaption);
|
||||
|
||||
await scenario.aliceMessages.expectMessageImageLoaded(imageName);
|
||||
await expect(scenario.bobMessages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
|
||||
await scenario.bobMessages.expectMessageImageLoaded(imageName);
|
||||
});
|
||||
|
||||
await test.step('Alice sends generic file attachment and Bob receives it', async () => {
|
||||
await scenario.aliceMessages.attachFiles([fileAttachment]);
|
||||
await scenario.aliceMessages.sendMessage(fileCaption);
|
||||
|
||||
await expect(scenario.bobMessages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.bob.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('renders link embeds for shared links', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
const messageText = `Useful docs ${MOCK_EMBED_URL}`;
|
||||
|
||||
await test.step('Alice shares a link in chat', async () => {
|
||||
await scenario.aliceMessages.sendMessage(messageText);
|
||||
await expect(scenario.bobMessages.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Both users see mocked link embed metadata', async () => {
|
||||
await expect(scenario.aliceMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.bobMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.bob.page.getByText(MOCK_EMBED_DESCRIPTION)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('sends KLIPY GIF messages with mocked API responses', async ({ createClient }) => {
|
||||
const scenario = await createChatScenario(createClient);
|
||||
|
||||
await test.step('Alice opens GIF picker and sends mocked GIF', async () => {
|
||||
await scenario.aliceMessages.openGifPicker();
|
||||
await scenario.aliceMessages.selectFirstGif();
|
||||
});
|
||||
|
||||
await test.step('Bob sees GIF message sync', async () => {
|
||||
await scenario.aliceMessages.expectMessageImageLoaded('KLIPY GIF');
|
||||
await scenario.bobMessages.expectMessageImageLoaded('KLIPY GIF');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface ChatScenario {
|
||||
alice: Client;
|
||||
bob: Client;
|
||||
aliceRoom: ChatRoomPage;
|
||||
bobRoom: ChatRoomPage;
|
||||
aliceMessages: ChatMessagesPage;
|
||||
bobMessages: ChatMessagesPage;
|
||||
}
|
||||
|
||||
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
|
||||
const suffix = uniqueName('chat');
|
||||
const serverName = `Chat Server ${suffix}`;
|
||||
const aliceCredentials = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bobCredentials = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installChatFeatureMocks(alice.page);
|
||||
await installChatFeatureMocks(bob.page);
|
||||
|
||||
const aliceRegisterPage = new RegisterPage(alice.page);
|
||||
const bobRegisterPage = new RegisterPage(bob.page);
|
||||
|
||||
await aliceRegisterPage.goto();
|
||||
await aliceRegisterPage.register(
|
||||
aliceCredentials.username,
|
||||
aliceCredentials.displayName,
|
||||
aliceCredentials.password
|
||||
);
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
|
||||
await bobRegisterPage.goto();
|
||||
await bobRegisterPage.register(
|
||||
bobCredentials.username,
|
||||
bobCredentials.displayName,
|
||||
bobCredentials.password
|
||||
);
|
||||
|
||||
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
|
||||
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearchPage.createServer(serverName, {
|
||||
description: 'E2E chat server for messaging feature coverage'
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await bobSearchPage.searchInput.fill(serverName);
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.click();
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||
const bobMessages = new ChatMessagesPage(bob.page);
|
||||
|
||||
await aliceMessages.waitForReady();
|
||||
await bobMessages.waitForReady();
|
||||
|
||||
return {
|
||||
alice,
|
||||
bob,
|
||||
aliceRoom,
|
||||
bobRoom,
|
||||
aliceMessages,
|
||||
bobMessages
|
||||
};
|
||||
}
|
||||
|
||||
async function installChatFeatureMocks(page: Page): Promise<void> {
|
||||
await page.route('**/api/klipy/config', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/klipy/gifs**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
hasNext: false,
|
||||
results: [
|
||||
{
|
||||
id: 'mock-gif-1',
|
||||
slug: 'mock-gif-1',
|
||||
title: 'Mock Celebration GIF',
|
||||
url: MOCK_GIF_IMAGE_URL,
|
||||
previewUrl: MOCK_GIF_IMAGE_URL,
|
||||
width: 64,
|
||||
height: 64
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/link-metadata**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const requestedTargetUrl = requestUrl.searchParams.get('url') ?? '';
|
||||
|
||||
if (requestedTargetUrl === MOCK_EMBED_URL) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
title: MOCK_EMBED_TITLE,
|
||||
description: MOCK_EMBED_DESCRIPTION,
|
||||
imageUrl: MOCK_GIF_IMAGE_URL,
|
||||
siteName: 'Mock Docs'
|
||||
})
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ failed: true })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
||||
return {
|
||||
name,
|
||||
mimeType,
|
||||
base64: Buffer.from(content, 'utf8').toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockSvgMarkup(label: string): string {
|
||||
return [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
|
||||
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
|
||||
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
|
||||
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
|
||||
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
|
||||
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
|
||||
'</svg>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
chromium,
|
||||
type BrowserContext,
|
||||
type Page
|
||||
} from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||
import { LoginPage } from '../../pages/login.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
|
||||
interface TestUser {
|
||||
displayName: string;
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface AvatarUploadPayload {
|
||||
buffer: Buffer;
|
||||
dataUrl: string;
|
||||
mimeType: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PersistentClient {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
user: TestUser;
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
interface ProfileMetadata {
|
||||
description?: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||
const GIF_FRAME_MARKER = Buffer.from([
|
||||
0x21,
|
||||
0xF9,
|
||||
0x04
|
||||
]);
|
||||
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
||||
0x21,
|
||||
0xFF,
|
||||
0x0B,
|
||||
0x4E,
|
||||
0x45,
|
||||
0x54,
|
||||
0x53,
|
||||
0x43,
|
||||
0x41,
|
||||
0x50,
|
||||
0x45,
|
||||
0x32,
|
||||
0x2E,
|
||||
0x30,
|
||||
0x03,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00
|
||||
]);
|
||||
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
test.describe('Profile avatar sync', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => {
|
||||
const suffix = uniqueName('avatar');
|
||||
const serverName = `Avatar Sync Server ${suffix}`;
|
||||
const messageText = `Avatar sync message ${suffix}`;
|
||||
const avatarA = buildAnimatedGifUpload('alpha');
|
||||
const avatarB = buildAnimatedGifUpload('beta');
|
||||
const aliceUser: TestUser = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bobUser: TestUser = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const carolUser: TestUser = {
|
||||
username: `carol_${suffix}`,
|
||||
displayName: 'Carol',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const clients: PersistentClient[] = [];
|
||||
|
||||
try {
|
||||
const alice = await createPersistentClient(aliceUser, testServer.port);
|
||||
const bob = await createPersistentClient(bobUser, testServer.port);
|
||||
|
||||
clients.push(alice, bob);
|
||||
|
||||
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
|
||||
await registerUser(alice);
|
||||
await registerUser(bob);
|
||||
|
||||
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearchPage.createServer(serverName, {
|
||||
description: 'Avatar synchronization E2E coverage'
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await joinServerFromSearch(bob.page, serverName);
|
||||
await waitForRoomReady(alice.page);
|
||||
await waitForRoomReady(bob.page);
|
||||
await waitForConnectedPeerCount(alice.page, 1);
|
||||
await waitForConnectedPeerCount(bob.page, 1);
|
||||
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
||||
});
|
||||
|
||||
const roomUrl = alice.page.url();
|
||||
|
||||
await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => {
|
||||
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA);
|
||||
|
||||
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl);
|
||||
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl);
|
||||
});
|
||||
|
||||
await test.step('Alice sees the updated avatar in voice controls', async () => {
|
||||
await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL);
|
||||
await joinVoiceChannel(alice.page, VOICE_CHANNEL);
|
||||
await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl);
|
||||
});
|
||||
|
||||
const carol = await createPersistentClient(carolUser, testServer.port);
|
||||
|
||||
clients.push(carol);
|
||||
|
||||
await test.step('Carol joins after the first change and sees the updated avatar', async () => {
|
||||
await registerUser(carol);
|
||||
await joinServerFromSearch(carol.page, serverName);
|
||||
await waitForRoomReady(carol.page);
|
||||
await waitForConnectedPeerCount(alice.page, 2);
|
||||
await waitForConnectedPeerCount(carol.page, 1);
|
||||
|
||||
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
|
||||
});
|
||||
|
||||
await test.step('Alice avatar is used in chat messages for everyone in the room', async () => {
|
||||
const aliceMessagesPage = new ChatMessagesPage(alice.page);
|
||||
|
||||
await aliceMessagesPage.sendMessage(messageText);
|
||||
|
||||
await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl);
|
||||
await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl);
|
||||
await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl);
|
||||
});
|
||||
|
||||
await test.step('Alice changes the avatar again and all three users see the update in real time', async () => {
|
||||
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB);
|
||||
|
||||
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
|
||||
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
|
||||
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
|
||||
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
|
||||
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
|
||||
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
|
||||
await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl);
|
||||
});
|
||||
|
||||
await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => {
|
||||
await restartPersistentClient(bob, testServer.port);
|
||||
await openRoomAfterRestart(bob, roomUrl);
|
||||
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
|
||||
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
|
||||
|
||||
await restartPersistentClient(carol, testServer.port);
|
||||
await openRoomAfterRestart(carol, roomUrl);
|
||||
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
|
||||
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
|
||||
|
||||
await restartPersistentClient(alice, testServer.port);
|
||||
await openRoomAfterRestart(alice, roomUrl);
|
||||
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
|
||||
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
|
||||
});
|
||||
} finally {
|
||||
await Promise.all(clients.map(async (client) => {
|
||||
await closePersistentClient(client);
|
||||
await rm(client.userDataDir, { recursive: true, force: true });
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Profile metadata sync', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('syncs display name and description changes for online and late-joining users and persists after restart', async ({ testServer }) => {
|
||||
const suffix = uniqueName('profile');
|
||||
const serverName = `Profile Sync Server ${suffix}`;
|
||||
const messageText = `Profile sync message ${suffix}`;
|
||||
const firstProfile: ProfileMetadata = {
|
||||
displayName: `Alice One ${suffix}`,
|
||||
description: `First synced profile description ${suffix}`
|
||||
};
|
||||
const secondProfile: ProfileMetadata = {
|
||||
displayName: `Alice Two ${suffix}`,
|
||||
description: `Second synced profile description ${suffix}`
|
||||
};
|
||||
const aliceUser: TestUser = {
|
||||
username: `alice_${suffix}`,
|
||||
displayName: 'Alice',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const bobUser: TestUser = {
|
||||
username: `bob_${suffix}`,
|
||||
displayName: 'Bob',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const carolUser: TestUser = {
|
||||
username: `carol_${suffix}`,
|
||||
displayName: 'Carol',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
const clients: PersistentClient[] = [];
|
||||
|
||||
try {
|
||||
const alice = await createPersistentClient(aliceUser, testServer.port);
|
||||
const bob = await createPersistentClient(bobUser, testServer.port);
|
||||
|
||||
clients.push(alice, bob);
|
||||
|
||||
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
|
||||
await registerUser(alice);
|
||||
await registerUser(bob);
|
||||
|
||||
const aliceSearchPage = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearchPage.createServer(serverName, {
|
||||
description: 'Profile synchronization E2E coverage'
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await joinServerFromSearch(bob.page, serverName);
|
||||
await waitForRoomReady(alice.page);
|
||||
await waitForRoomReady(bob.page);
|
||||
await waitForConnectedPeerCount(alice.page, 1);
|
||||
await waitForConnectedPeerCount(bob.page, 1);
|
||||
await expectUserRowVisible(bob.page, aliceUser.displayName);
|
||||
});
|
||||
|
||||
const roomUrl = alice.page.url();
|
||||
|
||||
await test.step('Alice updates her profile while Bob is online and Bob sees it live', async () => {
|
||||
await updateProfileFromRoomSidebar(alice.page, {
|
||||
displayName: aliceUser.displayName
|
||||
}, firstProfile);
|
||||
|
||||
await expectUserRowVisible(alice.page, firstProfile.displayName);
|
||||
await expectUserRowVisible(bob.page, firstProfile.displayName);
|
||||
await expectProfileCardDetails(bob.page, firstProfile);
|
||||
});
|
||||
|
||||
const carol = await createPersistentClient(carolUser, testServer.port);
|
||||
|
||||
clients.push(carol);
|
||||
|
||||
await test.step('Carol joins after the first change and sees the updated profile', async () => {
|
||||
await registerUser(carol);
|
||||
await joinServerFromSearch(carol.page, serverName);
|
||||
await waitForRoomReady(carol.page);
|
||||
await waitForConnectedPeerCount(alice.page, 2);
|
||||
await waitForConnectedPeerCount(carol.page, 1);
|
||||
|
||||
await expectUserRowVisible(carol.page, firstProfile.displayName);
|
||||
await expectProfileCardDetails(carol.page, firstProfile);
|
||||
});
|
||||
|
||||
await test.step('Alice changes her profile again and new chat messages use the latest display name', async () => {
|
||||
await updateProfileFromRoomSidebar(alice.page, firstProfile, secondProfile);
|
||||
|
||||
await expectUserRowVisible(alice.page, secondProfile.displayName);
|
||||
await expectUserRowVisible(bob.page, secondProfile.displayName);
|
||||
await expectUserRowVisible(carol.page, secondProfile.displayName);
|
||||
await expectProfileCardDetails(bob.page, secondProfile);
|
||||
await expectProfileCardDetails(carol.page, secondProfile);
|
||||
|
||||
const aliceMessagesPage = new ChatMessagesPage(alice.page);
|
||||
|
||||
await aliceMessagesPage.sendMessage(messageText);
|
||||
|
||||
await expectChatMessageSenderName(alice.page, messageText, secondProfile.displayName);
|
||||
await expectChatMessageSenderName(bob.page, messageText, secondProfile.displayName);
|
||||
await expectChatMessageSenderName(carol.page, messageText, secondProfile.displayName);
|
||||
});
|
||||
|
||||
await test.step('Bob, Carol, and Alice keep the latest profile after a full app restart', async () => {
|
||||
await restartPersistentClient(bob, testServer.port);
|
||||
await openRoomAfterRestart(bob, roomUrl);
|
||||
await expectUserRowVisible(bob.page, secondProfile.displayName);
|
||||
await expectProfileCardDetails(bob.page, secondProfile);
|
||||
|
||||
await restartPersistentClient(carol, testServer.port);
|
||||
await openRoomAfterRestart(carol, roomUrl);
|
||||
await expectUserRowVisible(carol.page, secondProfile.displayName);
|
||||
await expectProfileCardDetails(carol.page, secondProfile);
|
||||
|
||||
await restartPersistentClient(alice, testServer.port);
|
||||
await openRoomAfterRestart(alice, roomUrl);
|
||||
await expectUserRowVisible(alice.page, secondProfile.displayName);
|
||||
await expectProfileCardDetails(alice.page, secondProfile);
|
||||
});
|
||||
} finally {
|
||||
await Promise.all(clients.map(async (client) => {
|
||||
await closePersistentClient(client);
|
||||
await rm(client.userDataDir, { recursive: true, force: true });
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
|
||||
const session = await launchPersistentSession(userDataDir, testServerPort);
|
||||
|
||||
return {
|
||||
context: session.context,
|
||||
page: session.page,
|
||||
user,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
|
||||
await closePersistentClient(client);
|
||||
|
||||
const session = await launchPersistentSession(client.userDataDir, testServerPort);
|
||||
|
||||
client.context = session.context;
|
||||
client.page = session.page;
|
||||
}
|
||||
|
||||
async function closePersistentClient(client: PersistentClient): Promise<void> {
|
||||
try {
|
||||
await client.context.close();
|
||||
} catch {
|
||||
// Ignore repeated cleanup attempts during finally.
|
||||
}
|
||||
}
|
||||
|
||||
async function launchPersistentSession(
|
||||
userDataDir: string,
|
||||
testServerPort: number
|
||||
): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||
args: CLIENT_LAUNCH_ARGS,
|
||||
baseURL: 'http://localhost:4200',
|
||||
permissions: ['microphone', 'camera']
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServerPort);
|
||||
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
|
||||
await installWebRTCTracking(page);
|
||||
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function registerUser(client: PersistentClient): Promise<void> {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await retryTransientNavigation(() => registerPage.goto());
|
||||
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
|
||||
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
const serverCard = page.locator('button', { hasText: serverName }).first();
|
||||
|
||||
await searchPage.searchInput.fill(serverName);
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function ensureVoiceChannelExists(page: Page, channelName: string): Promise<void> {
|
||||
const chatRoom = new ChatRoomPage(page);
|
||||
const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true });
|
||||
|
||||
if (await existingVoiceChannel.count() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await chatRoom.openCreateVoiceChannelDialog();
|
||||
await chatRoom.createChannel(channelName);
|
||||
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function joinVoiceChannel(page: Page, channelName: string): Promise<void> {
|
||||
const chatRoom = new ChatRoomPage(page);
|
||||
|
||||
await chatRoom.joinVoiceChannel(channelName);
|
||||
await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function uploadAvatarFromRoomSidebar(
|
||||
page: Page,
|
||||
displayName: string,
|
||||
avatar: AvatarUploadPayload
|
||||
): Promise<void> {
|
||||
const currentUserRow = getUserRow(page, displayName);
|
||||
const profileFileInput = page.locator('app-profile-card input[type="file"]');
|
||||
const applyButton = page.getByRole('button', { name: 'Apply picture' });
|
||||
|
||||
await expect(currentUserRow).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
if (await profileFileInput.count() === 0) {
|
||||
await currentUserRow.click();
|
||||
await expect(profileFileInput).toBeAttached({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
await profileFileInput.setInputFiles({
|
||||
name: avatar.name,
|
||||
mimeType: avatar.mimeType,
|
||||
buffer: avatar.buffer
|
||||
});
|
||||
|
||||
await expect(applyButton).toBeVisible({ timeout: 10_000 });
|
||||
await applyButton.click();
|
||||
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function updateProfileFromRoomSidebar(
|
||||
page: Page,
|
||||
currentProfile: ProfileMetadata,
|
||||
nextProfile: ProfileMetadata
|
||||
): Promise<void> {
|
||||
const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName);
|
||||
const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true });
|
||||
|
||||
await expect(displayNameButton).toBeVisible({ timeout: 10_000 });
|
||||
await displayNameButton.click();
|
||||
|
||||
const displayNameInput = profileCard.locator('input[type="text"]').first();
|
||||
|
||||
await expect(displayNameInput).toBeVisible({ timeout: 10_000 });
|
||||
await displayNameInput.fill(nextProfile.displayName);
|
||||
await displayNameInput.blur();
|
||||
|
||||
await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
const currentDescriptionText = currentProfile.description || 'Add a description';
|
||||
|
||||
await profileCard.getByText(currentDescriptionText, { exact: true }).click();
|
||||
|
||||
const descriptionInput = profileCard.locator('textarea').first();
|
||||
|
||||
await expect(descriptionInput).toBeVisible({ timeout: 10_000 });
|
||||
await descriptionInput.fill(nextProfile.description || '');
|
||||
await descriptionInput.blur();
|
||||
|
||||
await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
if (nextProfile.description) {
|
||||
await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
|
||||
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
|
||||
|
||||
if (client.page.url().includes('/login')) {
|
||||
const loginPage = new LoginPage(client.page);
|
||||
|
||||
await loginPage.login(client.user.username, client.user.password);
|
||||
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
|
||||
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
await waitForRoomReady(client.page);
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
return await navigate();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
|
||||
|
||||
if (!isTransientNavigationError || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Navigation failed after ${attempts} attempts`);
|
||||
}
|
||||
|
||||
async function waitForRoomReady(page: Page): Promise<void> {
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await messagesPage.waitForReady();
|
||||
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
|
||||
await page.waitForFunction((expectedCount) => {
|
||||
const connections = (window as {
|
||||
__rtcConnections?: RTCPeerConnection[];
|
||||
}).__rtcConnections ?? [];
|
||||
|
||||
return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
|
||||
}, count, { timeout });
|
||||
}
|
||||
|
||||
async function openProfileCardFromUserRow(page: Page, displayName: string) {
|
||||
await closeProfileCard(page);
|
||||
|
||||
const row = getUserRow(page, displayName);
|
||||
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
await row.click();
|
||||
|
||||
const profileCard = page.locator('app-profile-card');
|
||||
|
||||
await expect(profileCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
return profileCard;
|
||||
}
|
||||
|
||||
async function closeProfileCard(page: Page): Promise<void> {
|
||||
const profileCard = page.locator('app-profile-card');
|
||||
|
||||
if (await profileCard.count() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await expect(profileCard).toBeVisible({ timeout: 1_000 });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.mouse.click(8, 8);
|
||||
await expect(profileCard).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
function getUserRow(page: Page, displayName: string) {
|
||||
const usersSidePanel = page.locator('app-rooms-side-panel').last();
|
||||
|
||||
return usersSidePanel.locator('[role="button"]').filter({
|
||||
has: page.getByText(displayName, { exact: true })
|
||||
})
|
||||
.first();
|
||||
}
|
||||
|
||||
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
|
||||
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise<void> {
|
||||
const profileCard = await openProfileCardFromUserRow(page, profile.displayName);
|
||||
|
||||
await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
if (profile.description) {
|
||||
await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
await closeProfileCard(page);
|
||||
}
|
||||
|
||||
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
|
||||
const row = getUserRow(page, displayName);
|
||||
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect.poll(async () => {
|
||||
const image = row.locator('img').first();
|
||||
|
||||
if (await image.count() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return image.getAttribute('src');
|
||||
}, {
|
||||
timeout: 20_000,
|
||||
message: `${displayName} avatar src should update`
|
||||
}).toBe(expectedDataUrl);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const image = row.locator('img').first();
|
||||
|
||||
if (await image.count() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return image.evaluate((element) => {
|
||||
const img = element as HTMLImageElement;
|
||||
|
||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||
});
|
||||
}, {
|
||||
timeout: 20_000,
|
||||
message: `${displayName} avatar image should load`
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise<void> {
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
const messageItem = messagesPage.getMessageItemByText(messageText);
|
||||
|
||||
await expect(messageItem).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect.poll(async () => {
|
||||
const image = messageItem.locator('app-user-avatar img').first();
|
||||
|
||||
if (await image.count() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return image.getAttribute('src');
|
||||
}, {
|
||||
timeout: 20_000,
|
||||
message: `Chat message avatar for "${messageText}" should update`
|
||||
}).toBe(expectedDataUrl);
|
||||
}
|
||||
|
||||
async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise<void> {
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
const messageItem = messagesPage.getMessageItemByText(messageText);
|
||||
|
||||
await expect(messageItem).toBeVisible({ timeout: 20_000 });
|
||||
await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
|
||||
const voiceControls = page.locator('app-voice-controls');
|
||||
|
||||
await expect(voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect.poll(async () => {
|
||||
const image = voiceControls.locator('app-user-avatar img').first();
|
||||
|
||||
if (await image.count() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return image.getAttribute('src');
|
||||
}, {
|
||||
timeout: 20_000,
|
||||
message: 'Voice controls avatar should update'
|
||||
}).toBe(expectedDataUrl);
|
||||
}
|
||||
|
||||
function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
|
||||
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
|
||||
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
|
||||
|
||||
if (frameStart < 0) {
|
||||
throw new Error('Failed to locate GIF frame marker for animated avatar payload');
|
||||
}
|
||||
|
||||
const header = baseGif.subarray(0, frameStart);
|
||||
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||
const commentData = Buffer.from(label, 'ascii');
|
||||
const commentExtension = Buffer.concat([
|
||||
Buffer.from([
|
||||
0x21,
|
||||
0xFE,
|
||||
commentData.length
|
||||
]),
|
||||
commentData,
|
||||
Buffer.from([0x00])
|
||||
]);
|
||||
const buffer = Buffer.concat([
|
||||
header,
|
||||
NETSCAPE_LOOP_EXTENSION,
|
||||
commentExtension,
|
||||
frame,
|
||||
frame,
|
||||
Buffer.from([0x3B])
|
||||
]);
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
return {
|
||||
buffer,
|
||||
dataUrl: `data:image/gif;base64,${base64}`,
|
||||
mimeType: 'image/gif',
|
||||
name: `animated-avatar-${label}.gif`
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import {
|
||||
installWebRTCTracking,
|
||||
waitForPeerConnected,
|
||||
isPeerStillConnected,
|
||||
waitForAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForVideoFlow,
|
||||
waitForOutboundVideoFlow,
|
||||
waitForInboundVideoFlow,
|
||||
dumpRtcDiagnostics
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
|
||||
/**
|
||||
* Screen sharing E2E tests: verify video, screen-share audio, and voice audio
|
||||
* flow correctly between users during screen sharing.
|
||||
*
|
||||
* Uses the same dedicated-browser-per-client infrastructure as voice tests.
|
||||
* getDisplayMedia is monkey-patched to return a synthetic canvas video stream
|
||||
* + 880 Hz oscillator audio, bypassing the browser picker dialog.
|
||||
*/
|
||||
|
||||
const ALICE = { username: `alice_ss_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' };
|
||||
const BOB = { username: `bob_ss_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' };
|
||||
const SERVER_NAME = `SS Test ${Date.now()}`;
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
/** Register a user and navigate to /search. */
|
||||
async function registerUser(page: import('@playwright/test').Page, user: typeof ALICE) {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await expect(registerPage.submitButton).toBeVisible();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Both users register → Alice creates server → Bob joins. */
|
||||
async function setupServerWithBothUsers(
|
||||
alice: { page: import('@playwright/test').Page },
|
||||
bob: { page: import('@playwright/test').Page }
|
||||
) {
|
||||
await registerUser(alice.page, ALICE);
|
||||
await registerUser(bob.page, BOB);
|
||||
|
||||
// Alice creates server
|
||||
const aliceSearch = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearch.createServer(SERVER_NAME, { description: 'Screen share E2E' });
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
// Bob joins server
|
||||
const bobSearch = new ServerSearchPage(bob.page);
|
||||
|
||||
await bobSearch.searchInput.fill(SERVER_NAME);
|
||||
|
||||
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
||||
await serverCard.click();
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Ensure voice channel exists and both users join it. */
|
||||
async function joinVoiceTogether(
|
||||
alice: { page: import('@playwright/test').Page },
|
||||
bob: { page: import('@playwright/test').Page }
|
||||
) {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const existingChannel = alice.page
|
||||
.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: VOICE_CHANNEL, exact: true });
|
||||
|
||||
if (await existingChannel.count() === 0) {
|
||||
await aliceRoom.openCreateVoiceChannelDialog();
|
||||
await aliceRoom.createChannel(VOICE_CHANNEL);
|
||||
await expect(existingChannel).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
|
||||
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Wait for WebRTC + audio pipeline
|
||||
await waitForPeerConnected(alice.page, 30_000);
|
||||
await waitForPeerConnected(bob.page, 30_000);
|
||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
||||
|
||||
// Expand voice workspace on both clients so the demand-driven screen
|
||||
// share request flow can fire (requires connectRemoteShares = true).
|
||||
// Click the "VIEW" badge that appears next to the active voice channel.
|
||||
const aliceView = alice.page.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: /view/i })
|
||||
.first();
|
||||
const bobView = bob.page.locator('app-rooms-side-panel')
|
||||
.getByRole('button', { name: /view/i })
|
||||
.first();
|
||||
|
||||
await expect(aliceView).toBeVisible({ timeout: 10_000 });
|
||||
await aliceView.click();
|
||||
await expect(alice.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(bobView).toBeVisible({ timeout: 10_000 });
|
||||
await bobView.click();
|
||||
await expect(bob.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Re-verify audio stats are present after workspace expansion (the VIEW
|
||||
// click can trigger renegotiation which briefly disrupts audio).
|
||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
||||
}
|
||||
|
||||
function expectFlowing(
|
||||
delta: { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number },
|
||||
label: string
|
||||
) {
|
||||
expect(
|
||||
delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0,
|
||||
`${label} should be sending`
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0,
|
||||
`${label} should be receiving`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Screen sharing', () => {
|
||||
test('single user screen share: video and audio flow to receiver, voice audio continues', async ({ createClient }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installWebRTCTracking(alice.page);
|
||||
await installWebRTCTracking(bob.page);
|
||||
|
||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||
|
||||
// ── Setup: register, server, voice ────────────────────────────
|
||||
|
||||
await test.step('Setup server and voice channel', async () => {
|
||||
await setupServerWithBothUsers(alice, bob);
|
||||
await joinVoiceTogether(alice, bob);
|
||||
});
|
||||
|
||||
// ── Verify voice audio before screen share ────────────────────
|
||||
|
||||
await test.step('Voice audio flows before screen share', async () => {
|
||||
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
|
||||
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
|
||||
|
||||
expectFlowing(aliceDelta, 'Alice voice');
|
||||
expectFlowing(bobDelta, 'Bob voice');
|
||||
});
|
||||
|
||||
// ── Alice starts screen sharing ───────────────────────────────
|
||||
|
||||
await test.step('Alice starts screen sharing', async () => {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
|
||||
await aliceRoom.startScreenShare();
|
||||
|
||||
// Screen share button should show active state (MonitorOff icon)
|
||||
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── Verify screen share video flows ───────────────────────────
|
||||
|
||||
await test.step('Screen share video flows from Alice to Bob', async () => {
|
||||
// Screen share is unidirectional: Alice sends video, Bob receives it.
|
||||
const aliceVideo = await waitForOutboundVideoFlow(alice.page, 30_000);
|
||||
const bobVideo = await waitForInboundVideoFlow(bob.page, 30_000);
|
||||
|
||||
if (aliceVideo.outboundBytesDelta === 0 || bobVideo.inboundBytesDelta === 0) {
|
||||
console.log('[Alice RTC]\n' + await dumpRtcDiagnostics(alice.page));
|
||||
console.log('[Bob RTC]\n' + await dumpRtcDiagnostics(bob.page));
|
||||
}
|
||||
|
||||
expect(
|
||||
aliceVideo.outboundBytesDelta > 0 || aliceVideo.outboundPacketsDelta > 0,
|
||||
'Alice should be sending screen share video'
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
bobVideo.inboundBytesDelta > 0 || bobVideo.inboundPacketsDelta > 0,
|
||||
'Bob should be receiving screen share video'
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── Verify voice audio continues during screen share ──────────
|
||||
|
||||
await test.step('Voice audio continues during screen share', async () => {
|
||||
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
|
||||
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
|
||||
|
||||
expectFlowing(aliceAudio, 'Alice voice during screen share');
|
||||
expectFlowing(bobAudio, 'Bob voice during screen share');
|
||||
});
|
||||
|
||||
// ── Bob can hear Alice talk while she screen shares ───────────
|
||||
|
||||
await test.step('Bob receives audio from Alice during screen share', async () => {
|
||||
// Specifically check Bob is receiving audio (from Alice's voice)
|
||||
const bobAudio = await waitForAudioFlow(bob.page, 15_000);
|
||||
|
||||
expect(
|
||||
bobAudio.inboundBytesDelta > 0,
|
||||
'Bob should receive voice audio while Alice screen shares'
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── Alice stops screen sharing ────────────────────────────────
|
||||
|
||||
await test.step('Alice stops screen sharing', async () => {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
|
||||
await aliceRoom.stopScreenShare();
|
||||
|
||||
// Active icon should disappear - regular Monitor icon shown instead
|
||||
await expect(
|
||||
aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── Voice audio still works after screen share ends ───────────
|
||||
|
||||
await test.step('Voice audio resumes normally after screen share stops', async () => {
|
||||
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
|
||||
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
|
||||
|
||||
expectFlowing(aliceAudio, 'Alice voice after screen share');
|
||||
expectFlowing(bobAudio, 'Bob voice after screen share');
|
||||
});
|
||||
});
|
||||
|
||||
test('multiple users screen share simultaneously', async ({ createClient }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installWebRTCTracking(alice.page);
|
||||
await installWebRTCTracking(bob.page);
|
||||
|
||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||
|
||||
await test.step('Setup server and voice channel', async () => {
|
||||
await setupServerWithBothUsers(alice, bob);
|
||||
await joinVoiceTogether(alice, bob);
|
||||
});
|
||||
|
||||
// ── Both users start screen sharing ───────────────────────────
|
||||
|
||||
await test.step('Alice starts screen sharing', async () => {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
|
||||
await aliceRoom.startScreenShare();
|
||||
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step('Bob starts screen sharing', async () => {
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
|
||||
await bobRoom.startScreenShare();
|
||||
await expect(bobRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── Verify video flows in both directions ─────────────────────
|
||||
|
||||
await test.step('Video flows bidirectionally with both screen shares active', async () => {
|
||||
// Both sharing: each page sends and receives video
|
||||
const aliceVideo = await waitForVideoFlow(alice.page, 30_000);
|
||||
const bobVideo = await waitForVideoFlow(bob.page, 30_000);
|
||||
|
||||
expectFlowing(aliceVideo, 'Alice screen share video');
|
||||
expectFlowing(bobVideo, 'Bob screen share video');
|
||||
});
|
||||
|
||||
// ── Voice audio continues with dual screen shares ─────────────
|
||||
|
||||
await test.step('Voice audio continues with both users screen sharing', async () => {
|
||||
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
|
||||
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
|
||||
|
||||
expectFlowing(aliceAudio, 'Alice voice during dual screen share');
|
||||
expectFlowing(bobAudio, 'Bob voice during dual screen share');
|
||||
});
|
||||
|
||||
// ── Both stop screen sharing ──────────────────────────────────
|
||||
|
||||
await test.step('Both users stop screen sharing', async () => {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
|
||||
await aliceRoom.stopScreenShare();
|
||||
await expect(
|
||||
aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await bobRoom.stopScreenShare();
|
||||
await expect(
|
||||
bobRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('screen share connection stays stable for 10+ seconds', async ({ createClient }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await installWebRTCTracking(alice.page);
|
||||
await installWebRTCTracking(bob.page);
|
||||
|
||||
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
|
||||
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
|
||||
|
||||
await test.step('Setup server and voice channel', async () => {
|
||||
await setupServerWithBothUsers(alice, bob);
|
||||
await joinVoiceTogether(alice, bob);
|
||||
});
|
||||
|
||||
await test.step('Alice starts screen sharing', async () => {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
|
||||
await aliceRoom.startScreenShare();
|
||||
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for video pipeline to fully establish
|
||||
await waitForOutboundVideoFlow(alice.page, 30_000);
|
||||
await waitForInboundVideoFlow(bob.page, 30_000);
|
||||
});
|
||||
|
||||
// ── Stability checkpoints at 0s, 5s, 10s ─────────────────────
|
||||
|
||||
await test.step('Connection stays stable for 10+ seconds during screen share', async () => {
|
||||
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 - verify both video and audio still flowing
|
||||
const aliceVideo = await waitForOutboundVideoFlow(alice.page, 15_000);
|
||||
const bobVideo = await waitForInboundVideoFlow(bob.page, 15_000);
|
||||
|
||||
expect(
|
||||
aliceVideo.outboundBytesDelta > 0,
|
||||
'Alice still sending screen share video after 10s'
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
bobVideo.inboundBytesDelta > 0,
|
||||
'Bob still receiving screen share video after 10s'
|
||||
).toBe(true);
|
||||
|
||||
const aliceAudio = await waitForAudioFlow(alice.page, 15_000);
|
||||
const bobAudio = await waitForAudioFlow(bob.page, 15_000);
|
||||
|
||||
expectFlowing(aliceAudio, 'Alice voice after 10s screen share');
|
||||
expectFlowing(bobAudio, 'Bob voice after 10s screen share');
|
||||
});
|
||||
|
||||
// ── Clean disconnect ──────────────────────────────────────────
|
||||
|
||||
await test.step('Alice stops screen share and disconnects', async () => {
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
|
||||
await aliceRoom.stopScreenShare();
|
||||
await aliceRoom.disconnectButton.click();
|
||||
await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,7 @@ import {
|
||||
installWebRTCTracking,
|
||||
waitForPeerConnected,
|
||||
isPeerStillConnected,
|
||||
getAudioStatsDelta,
|
||||
waitForAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
dumpRtcDiagnostics
|
||||
getAudioStatsDelta
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
@@ -136,27 +133,21 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
||||
await test.step('WebRTC peer connection reaches "connected" state', async () => {
|
||||
await waitForPeerConnected(alice.page, 30_000);
|
||||
await waitForPeerConnected(bob.page, 30_000);
|
||||
|
||||
// Wait for audio RTP pipeline to appear before measuring deltas -
|
||||
// renegotiation after initial connect can temporarily remove stats.
|
||||
await waitForAudioStatsPresent(alice.page, 20_000);
|
||||
await waitForAudioStatsPresent(bob.page, 20_000);
|
||||
});
|
||||
|
||||
// ── Step 7: Verify audio is flowing in both directions ───────────
|
||||
|
||||
await test.step('Audio packets are flowing between Alice and Bob', async () => {
|
||||
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
|
||||
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
|
||||
// Wait a moment for audio pipeline to stabilize
|
||||
const aliceDelta = await getAudioStatsDelta(alice.page, 3_000);
|
||||
|
||||
if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0
|
||||
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {
|
||||
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
|
||||
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
|
||||
}
|
||||
expect(aliceDelta.outboundBytesDelta).toBeGreaterThan(0);
|
||||
expect(aliceDelta.inboundBytesDelta).toBeGreaterThan(0);
|
||||
|
||||
expectAudioFlow(aliceDelta, 'Alice');
|
||||
expectAudioFlow(bobDelta, 'Bob');
|
||||
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 ─────────────────────────
|
||||
@@ -199,11 +190,15 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
||||
}
|
||||
|
||||
// After 10s total, verify audio is still flowing
|
||||
const aliceDelta = await waitForAudioFlow(alice.page, 15_000);
|
||||
const bobDelta = await waitForAudioFlow(bob.page, 15_000);
|
||||
const aliceDelta = await getAudioStatsDelta(alice.page, 2_000);
|
||||
|
||||
expectAudioFlow(aliceDelta, 'Alice after 10s');
|
||||
expectAudioFlow(bobDelta, 'Bob after 10s');
|
||||
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 ──────────────────
|
||||
@@ -223,9 +218,9 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
||||
await aliceRoom.muteButton.click();
|
||||
|
||||
// After unmuting, outbound should resume
|
||||
const unmutedDelta = await waitForAudioFlow(alice.page, 15_000);
|
||||
const unmutedDelta = await getAudioStatsDelta(alice.page, 2_000);
|
||||
|
||||
expectAudioFlow(unmutedDelta, 'Alice after unmuting');
|
||||
expect(unmutedDelta.outboundBytesDelta, 'Audio should flow after unmuting').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── Step 11: Clean disconnect ────────────────────────────────────
|
||||
@@ -241,20 +236,3 @@ test.describe('Full user journey: register → server → voice chat', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectAudioFlow(delta: {
|
||||
outboundBytesDelta: number;
|
||||
inboundBytesDelta: number;
|
||||
outboundPacketsDelta: number;
|
||||
inboundPacketsDelta: number;
|
||||
}, label: string): void {
|
||||
expect(
|
||||
delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0,
|
||||
`${label} should be sending audio`
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0,
|
||||
`${label} should be receiving audio`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
setupSystemHandlers,
|
||||
setupWindowControlHandlers
|
||||
} from '../ipc';
|
||||
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||
|
||||
export function registerAppLifecycle(): void {
|
||||
app.whenReady().then(async () => {
|
||||
@@ -35,7 +34,6 @@ export function registerAppLifecycle(): void {
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
await createWindow();
|
||||
startIdleMonitor();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (getMainWindow()) {
|
||||
@@ -59,7 +57,6 @@ export function registerAppLifecycle(): void {
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
shutdownDesktopUpdater();
|
||||
stopIdleMonitor();
|
||||
await cleanupLinuxScreenShareAudioRouting();
|
||||
await destroyDatabase();
|
||||
app.quit();
|
||||
|
||||
@@ -10,12 +10,7 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
|
||||
oderId: user.oderId ?? null,
|
||||
username: user.username ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
description: user.description ?? null,
|
||||
profileUpdatedAt: user.profileUpdatedAt ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
avatarHash: user.avatarHash ?? null,
|
||||
avatarMime: user.avatarMime ?? null,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
|
||||
status: user.status ?? null,
|
||||
role: user.role ?? null,
|
||||
joinedAt: user.joinedAt ?? null,
|
||||
|
||||
@@ -46,12 +46,7 @@ export function rowToUser(row: UserEntity) {
|
||||
oderId: row.oderId ?? '',
|
||||
username: row.username ?? '',
|
||||
displayName: row.displayName ?? '',
|
||||
description: row.description ?? undefined,
|
||||
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
|
||||
avatarUrl: row.avatarUrl ?? undefined,
|
||||
avatarHash: row.avatarHash ?? undefined,
|
||||
avatarMime: row.avatarMime ?? undefined,
|
||||
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
|
||||
status: row.status ?? 'offline',
|
||||
role: row.role ?? 'member',
|
||||
joinedAt: row.joinedAt ?? 0,
|
||||
|
||||
@@ -66,12 +66,7 @@ export interface RoomMemberRecord {
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt?: number;
|
||||
role: RoomMemberRole;
|
||||
roleIds?: string[];
|
||||
joinedAt: number;
|
||||
@@ -340,33 +335,19 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
|
||||
const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
|
||||
const username = trimmedString(rawMember, 'username');
|
||||
const displayName = trimmedString(rawMember, 'displayName');
|
||||
const description = trimmedString(rawMember, 'description');
|
||||
const profileUpdatedAt = isFiniteNumber(rawMember['profileUpdatedAt']) ? rawMember['profileUpdatedAt'] : undefined;
|
||||
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
|
||||
const avatarHash = trimmedString(rawMember, 'avatarHash');
|
||||
const avatarMime = trimmedString(rawMember, 'avatarMime');
|
||||
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
|
||||
const member: RoomMemberRecord = {
|
||||
|
||||
return {
|
||||
id: normalizedId || normalizedKey,
|
||||
oderId: normalizedOderId || undefined,
|
||||
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
||||
profileUpdatedAt,
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
avatarHash: avatarHash || undefined,
|
||||
avatarMime: avatarMime || undefined,
|
||||
avatarUpdatedAt,
|
||||
role: normalizeRoomMemberRole(rawMember['role']),
|
||||
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(rawMember, 'description')) {
|
||||
member.description = description || undefined;
|
||||
}
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
||||
@@ -375,16 +356,6 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
||||
}
|
||||
|
||||
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||
const existingProfileUpdatedAt = existingMember.profileUpdatedAt ?? 0;
|
||||
const incomingProfileUpdatedAt = incomingMember.profileUpdatedAt ?? 0;
|
||||
const preferIncomingProfile = incomingProfileUpdatedAt === existingProfileUpdatedAt
|
||||
? preferIncoming
|
||||
: incomingProfileUpdatedAt > existingProfileUpdatedAt;
|
||||
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
|
||||
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
|
||||
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
|
||||
? preferIncoming
|
||||
: incomingAvatarUpdatedAt > existingAvatarUpdatedAt;
|
||||
|
||||
return {
|
||||
id: existingMember.id || incomingMember.id,
|
||||
@@ -392,23 +363,12 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
|
||||
username: preferIncoming
|
||||
? (incomingMember.username || existingMember.username)
|
||||
: (existingMember.username || incomingMember.username),
|
||||
displayName: preferIncomingProfile
|
||||
displayName: preferIncoming
|
||||
? (incomingMember.displayName || existingMember.displayName)
|
||||
: (existingMember.displayName || incomingMember.displayName),
|
||||
description: preferIncomingProfile
|
||||
? (Object.prototype.hasOwnProperty.call(incomingMember, 'description') ? incomingMember.description : existingMember.description)
|
||||
: existingMember.description,
|
||||
profileUpdatedAt: Math.max(existingProfileUpdatedAt, incomingProfileUpdatedAt) || undefined,
|
||||
avatarUrl: preferIncomingAvatar
|
||||
avatarUrl: preferIncoming
|
||||
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
||||
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
||||
avatarHash: preferIncomingAvatar
|
||||
? (incomingMember.avatarHash || existingMember.avatarHash)
|
||||
: (existingMember.avatarHash || incomingMember.avatarHash),
|
||||
avatarMime: preferIncomingAvatar
|
||||
? (incomingMember.avatarMime || existingMember.avatarMime)
|
||||
: (existingMember.avatarMime || incomingMember.avatarMime),
|
||||
avatarUpdatedAt: Math.max(existingAvatarUpdatedAt, incomingAvatarUpdatedAt) || undefined,
|
||||
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
||||
roleIds: preferIncoming
|
||||
? (incomingMember.roleIds || existingMember.roleIds)
|
||||
@@ -799,12 +759,7 @@ export async function replaceRoomRelations(
|
||||
oderId: member.oderId ?? null,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
description: member.description ?? null,
|
||||
profileUpdatedAt: member.profileUpdatedAt ?? null,
|
||||
avatarUrl: member.avatarUrl ?? null,
|
||||
avatarHash: member.avatarHash ?? null,
|
||||
avatarMime: member.avatarMime ?? null,
|
||||
avatarUpdatedAt: member.avatarUpdatedAt ?? null,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
lastSeenAt: member.lastSeenAt
|
||||
@@ -951,12 +906,7 @@ export async function loadRoomRelationsMap(
|
||||
oderId: row.oderId ?? undefined,
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
description: row.description ?? undefined,
|
||||
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
|
||||
avatarUrl: row.avatarUrl ?? undefined,
|
||||
avatarHash: row.avatarHash ?? undefined,
|
||||
avatarMime: row.avatarMime ?? undefined,
|
||||
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
|
||||
role: row.role,
|
||||
joinedAt: row.joinedAt,
|
||||
lastSeenAt: row.lastSeenAt
|
||||
|
||||
@@ -105,12 +105,7 @@ export interface UserPayload {
|
||||
oderId?: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt?: number;
|
||||
status?: string;
|
||||
role?: string;
|
||||
joinedAt?: number;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
@@ -21,93 +20,23 @@ import {
|
||||
import { settings } from '../settings';
|
||||
|
||||
let applicationDataSource: DataSource | undefined;
|
||||
let dbFilePath = '';
|
||||
let dbBackupPath = '';
|
||||
|
||||
// SQLite files start with this 16-byte header string.
|
||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||
|
||||
export function getDataSource(): DataSource | undefined {
|
||||
return applicationDataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `data` looks like a valid SQLite file
|
||||
* (correct header magic and at least one complete page).
|
||||
*/
|
||||
function isValidSqlite(data: Uint8Array): boolean {
|
||||
if (data.length < 100)
|
||||
return false;
|
||||
|
||||
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
|
||||
|
||||
return header === SQLITE_MAGIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back up the current DB file so there is always a recovery point.
|
||||
* If the main file is corrupted/empty but a valid backup exists,
|
||||
* restore the backup before the app loads the database.
|
||||
*/
|
||||
function safeguardDbFile(): Uint8Array | undefined {
|
||||
if (!fs.existsSync(dbFilePath))
|
||||
return undefined;
|
||||
|
||||
const data = new Uint8Array(fs.readFileSync(dbFilePath));
|
||||
|
||||
if (isValidSqlite(data)) {
|
||||
fs.copyFileSync(dbFilePath, dbBackupPath);
|
||||
console.log('[DB] Backed up database to', dbBackupPath);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
console.warn(`[DB] ${dbFilePath} appears corrupt (${data.length} bytes) - checking backup`);
|
||||
|
||||
if (fs.existsSync(dbBackupPath)) {
|
||||
const backup = new Uint8Array(fs.readFileSync(dbBackupPath));
|
||||
|
||||
if (isValidSqlite(backup)) {
|
||||
fs.copyFileSync(dbBackupPath, dbFilePath);
|
||||
console.warn('[DB] Restored database from backup', dbBackupPath);
|
||||
|
||||
return backup;
|
||||
}
|
||||
|
||||
console.error('[DB] Backup is also invalid - starting with a fresh database');
|
||||
} else {
|
||||
console.error('[DB] No backup available - starting with a fresh database');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the database to disk atomically: write a temp file first,
|
||||
* then rename it over the real file. rename() is atomic on the same
|
||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||
*/
|
||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
||||
|
||||
try {
|
||||
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||
await fsp.rename(tmpPath, dbFilePath);
|
||||
} catch (err) {
|
||||
await fsp.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbDir = path.join(userDataPath, 'metoyou');
|
||||
|
||||
await fsp.mkdir(dbDir, { recursive: true });
|
||||
dbFilePath = path.join(dbDir, settings.databaseName);
|
||||
dbBackupPath = dbFilePath + '.bak';
|
||||
const databaseFilePath = path.join(dbDir, settings.databaseName);
|
||||
|
||||
const database = safeguardDbFile();
|
||||
let database: Uint8Array | undefined;
|
||||
|
||||
if (fs.existsSync(databaseFilePath)) {
|
||||
database = fs.readFileSync(databaseFilePath);
|
||||
}
|
||||
|
||||
applicationDataSource = new DataSource({
|
||||
type: 'sqljs',
|
||||
@@ -130,12 +59,12 @@ export async function initializeDatabase(): Promise<void> {
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
autoSave: true,
|
||||
autoSaveCallback: atomicSave
|
||||
location: databaseFilePath
|
||||
});
|
||||
|
||||
try {
|
||||
await applicationDataSource.initialize();
|
||||
console.log('[DB] Connection initialised at:', dbFilePath);
|
||||
console.log('[DB] Connection initialised at:', databaseFilePath);
|
||||
|
||||
try {
|
||||
await applicationDataSource.runMigrations();
|
||||
|
||||
@@ -24,24 +24,9 @@ export class RoomMemberEntity {
|
||||
@Column('text')
|
||||
displayName!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
profileUpdatedAt!: number | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarHash!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarMime!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
avatarUpdatedAt!: number | null;
|
||||
|
||||
@Column('text')
|
||||
role!: 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
|
||||
@@ -18,24 +18,9 @@ export class UserEntity {
|
||||
@Column('text', { nullable: true })
|
||||
displayName!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
profileUpdatedAt!: number | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarHash!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarMime!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
avatarUpdatedAt!: number | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
status!: string | null;
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach
|
||||
} from 'vitest';
|
||||
|
||||
// Mock Electron modules before importing the module under test
|
||||
const mockGetSystemIdleTime = vi.fn(() => 0);
|
||||
const mockSend = vi.fn();
|
||||
const mockGetMainWindow = vi.fn(() => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend }
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
powerMonitor: {
|
||||
getSystemIdleTime: mockGetSystemIdleTime
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../window/create-window', () => ({
|
||||
getMainWindow: mockGetMainWindow
|
||||
}));
|
||||
|
||||
import {
|
||||
startIdleMonitor,
|
||||
stopIdleMonitor,
|
||||
getIdleState
|
||||
} from './idle-monitor';
|
||||
|
||||
describe('idle-monitor', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockGetSystemIdleTime.mockReturnValue(0);
|
||||
mockSend.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopIdleMonitor();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns active when idle time is below threshold', () => {
|
||||
mockGetSystemIdleTime.mockReturnValue(0);
|
||||
expect(getIdleState()).toBe('active');
|
||||
});
|
||||
|
||||
it('returns idle when idle time exceeds 15 minutes', () => {
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
expect(getIdleState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('sends idle-state-changed to renderer when transitioning to idle', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'idle');
|
||||
});
|
||||
|
||||
it('sends idle-state-changed to renderer when transitioning back to active', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
// Go idle
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
mockSend.mockClear();
|
||||
|
||||
// Go active
|
||||
mockGetSystemIdleTime.mockReturnValue(5);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'active');
|
||||
});
|
||||
|
||||
it('does not fire duplicates when state stays the same', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
// Only one transition, so only one call
|
||||
const idleCalls = mockSend.mock.calls.filter(
|
||||
([channel, state]: [string, string]) => channel === 'idle-state-changed' && state === 'idle'
|
||||
);
|
||||
|
||||
expect(idleCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('stops polling after stopIdleMonitor', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
mockSend.mockClear();
|
||||
|
||||
stopIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(0);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify when main window is null', () => {
|
||||
mockGetMainWindow.mockReturnValue(null);
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
mockGetMainWindow.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { powerMonitor } from 'electron';
|
||||
import { getMainWindow } from '../window/create-window';
|
||||
|
||||
const IDLE_THRESHOLD_SECONDS = 15 * 60; // 15 minutes
|
||||
const POLL_INTERVAL_MS = 10_000; // Check every 10 seconds
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let wasIdle = false;
|
||||
|
||||
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||
|
||||
export type IdleState = 'active' | 'idle';
|
||||
|
||||
/**
|
||||
* Starts polling `powerMonitor.getSystemIdleTime()` and notifies the
|
||||
* renderer whenever the user transitions between active and idle.
|
||||
*/
|
||||
export function startIdleMonitor(): void {
|
||||
if (pollTimer)
|
||||
return;
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||
const isIdle = idleSeconds >= IDLE_THRESHOLD_SECONDS;
|
||||
|
||||
if (isIdle !== wasIdle) {
|
||||
wasIdle = isIdle;
|
||||
const state: IdleState = isIdle ? 'idle' : 'active';
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IDLE_STATE_CHANGED_CHANNEL, state);
|
||||
}
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopIdleMonitor(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getIdleState(): IdleState {
|
||||
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||
|
||||
return idleSeconds >= IDLE_THRESHOLD_SECONDS ? 'idle' : 'active';
|
||||
}
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
} from '../update/desktop-updater';
|
||||
import { consumePendingDeepLink } from '../app/deep-links';
|
||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||
import { getIdleState } from '../idle/idle-monitor';
|
||||
import {
|
||||
getMainWindow,
|
||||
getWindowIconPath,
|
||||
@@ -529,7 +528,6 @@ export function setupSystemHandlers(): void {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
response.on('error', () => resolve(false));
|
||||
});
|
||||
|
||||
@@ -539,12 +537,7 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
||||
const allowedCommands = [
|
||||
'cut',
|
||||
'copy',
|
||||
'paste',
|
||||
'selectAll'
|
||||
] as const;
|
||||
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const;
|
||||
|
||||
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
||||
return;
|
||||
@@ -558,22 +551,10 @@ export function setupSystemHandlers(): void {
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'cut':
|
||||
webContents.cut();
|
||||
break;
|
||||
case 'copy':
|
||||
webContents.copy();
|
||||
break;
|
||||
case 'paste':
|
||||
webContents.paste();
|
||||
break;
|
||||
case 'selectAll':
|
||||
webContents.selectAll();
|
||||
break;
|
||||
case 'cut': webContents.cut(); break;
|
||||
case 'copy': webContents.copy(); break;
|
||||
case 'paste': webContents.paste(); break;
|
||||
case 'selectAll': webContents.selectAll(); break;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-idle-state', () => {
|
||||
return getIdleState();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddProfileAvatarMetadata1000000000006 implements MigrationInterface {
|
||||
name = 'AddProfileAvatarMetadata1000000000006';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarHash" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarMime" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUpdatedAt" INTEGER`);
|
||||
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarHash" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarMime" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarUpdatedAt" INTEGER`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// SQLite column removal requires table rebuilds. Keep rollback no-op.
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserProfileMetadata1000000000007 implements MigrationInterface {
|
||||
name = 'AddUserProfileMetadata1000000000007';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "description" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "profileUpdatedAt" INTEGER`);
|
||||
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "description" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "profileUpdatedAt" INTEGER`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// SQLite column removal requires table rebuilds. Keep rollback no-op.
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monit
|
||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -215,9 +214,6 @@ export interface ElectronAPI {
|
||||
contextMenuCommand: (command: string) => Promise<void>;
|
||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||
|
||||
getIdleState: () => Promise<'active' | 'idle'>;
|
||||
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||
|
||||
command: <T = unknown>(command: Command) => Promise<T>;
|
||||
query: <T = unknown>(query: Query) => Promise<T>;
|
||||
}
|
||||
@@ -337,19 +333,6 @@ const electronAPI: ElectronAPI = {
|
||||
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
||||
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||
|
||||
getIdleState: () => ipcRenderer.invoke('get-idle-state'),
|
||||
onIdleStateChanged: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, state: 'active' | 'idle') => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
|
||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||
};
|
||||
|
||||
@@ -15,37 +15,8 @@ let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let closeToTrayEnabled = true;
|
||||
let appQuitting = false;
|
||||
let youtubeRequestHeadersConfigured = false;
|
||||
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
|
||||
|
||||
function ensureYoutubeEmbedRequestHeaders(): void {
|
||||
if (youtubeRequestHeadersConfigured || !app.isPackaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
youtubeRequestHeadersConfigured = true;
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
{
|
||||
urls: [
|
||||
'https://www.youtube-nocookie.com/*',
|
||||
'https://www.youtube.com/*',
|
||||
'https://*.youtube.com/*',
|
||||
'https://*.googlevideo.com/*',
|
||||
'https://*.ytimg.com/*'
|
||||
]
|
||||
},
|
||||
(details, callback) => {
|
||||
const requestHeaders = { ...details.requestHeaders };
|
||||
|
||||
requestHeaders['Referer'] ??= YOUTUBE_EMBED_REFERRER;
|
||||
|
||||
callback({ requestHeaders });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getAssetPath(...segments: string[]): string {
|
||||
const basePath = app.isPackaged
|
||||
@@ -192,7 +163,6 @@ export async function createWindow(): Promise<void> {
|
||||
|
||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||
ensureTray();
|
||||
ensureYoutubeEmbedRequestHeaders();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
@@ -240,40 +210,6 @@ export async function createWindow(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
async (request, respond) => {
|
||||
// On Windows the system picker (useSystemPicker: true) is preferred.
|
||||
// This handler is only reached when the system picker is unavailable.
|
||||
// Include loopback audio when the renderer requested it so that
|
||||
// getDisplayMedia receives an audio track and the renderer-side
|
||||
// restrictOwnAudio constraint can keep the app's own voice playback
|
||||
// out of the captured stream.
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
const firstSource = sources[0];
|
||||
|
||||
if (firstSource) {
|
||||
respond({
|
||||
video: firstSource,
|
||||
...(request.audioRequested ? { audio: 'loopback' } : {})
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// desktopCapturer also unavailable
|
||||
}
|
||||
|
||||
respond({});
|
||||
},
|
||||
{ useSystemPicker: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['NODE_ENV'] === 'development') {
|
||||
const devUrl = process.env['SSL'] === 'true'
|
||||
? 'https://localhost:4200'
|
||||
|
||||
@@ -123,7 +123,7 @@ module.exports = tseslint.config(
|
||||
'complexity': ['warn',{ max:20 }],
|
||||
'curly': 'off',
|
||||
'eol-last': 'error',
|
||||
'id-denylist': ['warn','e','cb','i','c','any','string','String','Undefined','undefined','callback'],
|
||||
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
|
||||
'max-len': ['error',{ code:150, ignoreComments:true }],
|
||||
'new-parens': 'error',
|
||||
'newline-per-chained-call': 'error',
|
||||
@@ -172,7 +172,7 @@ module.exports = tseslint.config(
|
||||
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
||||
'max-statements-per-line': ['error', { max: 1 }],
|
||||
// Prevent single-character identifiers for variables/params; do not check object property names
|
||||
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_', 'x', 'y'] }],
|
||||
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
|
||||
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
||||
'padding-line-between-statements': [
|
||||
'error',
|
||||
|
||||
373
package-lock.json
generated
373
package-lock.json
generated
@@ -60,7 +60,6 @@
|
||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "21.2.0",
|
||||
@@ -80,7 +79,6 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "8.50.1",
|
||||
"vitest": "^4.1.4",
|
||||
"wait-on": "^7.2.0"
|
||||
}
|
||||
},
|
||||
@@ -11027,17 +11025,6 @@
|
||||
"@types/responselike": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
@@ -11319,13 +11306,6 @@
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -11485,13 +11465,6 @@
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mocha": {
|
||||
"version": "10.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
|
||||
"integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
@@ -12297,146 +12270,6 @@
|
||||
"vite": "^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
|
||||
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
|
||||
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
|
||||
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
|
||||
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.4",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
|
||||
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot/node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
|
||||
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
|
||||
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
@@ -13275,16 +13108,6 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/astral-regex": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||
@@ -14181,16 +14004,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -17670,16 +17483,6 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -17759,16 +17562,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
@@ -23745,17 +23538,6 @@
|
||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -27597,13 +27379,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -27998,13 +27773,6 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackframe": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
@@ -28030,13 +27798,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||
@@ -28910,13 +28671,6 @@
|
||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
@@ -28942,16 +28696,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
@@ -30823,106 +30567,6 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
|
||||
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.4",
|
||||
"@vitest/mocker": "4.1.4",
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"@vitest/runner": "4.1.4",
|
||||
"@vitest/snapshot": "4.1.4",
|
||||
"@vitest/spy": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.4",
|
||||
"@vitest/browser-preview": "4.1.4",
|
||||
"@vitest/browser-webdriverio": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"@vitest/coverage-v8": "4.1.4",
|
||||
"@vitest/ui": "4.1.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
@@ -31795,23 +31439,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -17,7 +17,7 @@
|
||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||
"test": "cd \"toju-app\" && vitest run",
|
||||
"test": "cd \"toju-app\" && ng test",
|
||||
"server:build": "cd server && npm run build",
|
||||
"server:start": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
@@ -110,7 +110,6 @@
|
||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "21.2.0",
|
||||
@@ -130,7 +129,6 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "8.50.1",
|
||||
"vitest": "^4.1.4",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
@@ -148,13 +146,9 @@
|
||||
"output": "dist-electron"
|
||||
},
|
||||
"files": [
|
||||
"!node_modules",
|
||||
"dist/client/**/*",
|
||||
"dist/electron/**/*",
|
||||
"node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
|
||||
"node_modules/@isaacs/cliui/**/*",
|
||||
"node_modules/@pkgjs/parseargs/**/*",
|
||||
"node_modules/@sqltools/formatter/**/*",
|
||||
"node_modules/**/*",
|
||||
"!node_modules/**/test/**/*",
|
||||
"!node_modules/**/tests/**/*",
|
||||
"!node_modules/**/*.d.ts",
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,4 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
@@ -19,77 +17,11 @@ import {
|
||||
import { serverMigrations } from '../migrations';
|
||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||
|
||||
function resolveDbFile(): string {
|
||||
const envPath = process.env.DB_PATH;
|
||||
|
||||
if (envPath) {
|
||||
return path.resolve(envPath);
|
||||
}
|
||||
|
||||
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
|
||||
}
|
||||
|
||||
const DB_FILE = resolveDbFile();
|
||||
const DB_BACKUP = DB_FILE + '.bak';
|
||||
const DATA_DIR = path.dirname(DB_FILE);
|
||||
// SQLite files start with this 16-byte header string.
|
||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||
const DATA_DIR = resolveRuntimePath('data');
|
||||
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
|
||||
|
||||
let applicationDataSource: DataSource | undefined;
|
||||
|
||||
/**
|
||||
* Returns true when `data` looks like a valid SQLite file
|
||||
* (correct header magic and at least one complete page).
|
||||
*/
|
||||
function isValidSqlite(data: Uint8Array): boolean {
|
||||
if (data.length < 100)
|
||||
return false;
|
||||
|
||||
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
|
||||
|
||||
return header === SQLITE_MAGIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back up the current DB file so there is always a recovery point.
|
||||
* If the main file is corrupted/empty but a valid backup exists,
|
||||
* restore the backup before the server loads the database.
|
||||
*/
|
||||
function safeguardDbFile(): Uint8Array | undefined {
|
||||
if (!fs.existsSync(DB_FILE))
|
||||
return undefined;
|
||||
|
||||
const data = new Uint8Array(fs.readFileSync(DB_FILE));
|
||||
|
||||
if (isValidSqlite(data)) {
|
||||
// Good file - rotate it into the backup slot.
|
||||
fs.copyFileSync(DB_FILE, DB_BACKUP);
|
||||
console.log('[DB] Backed up database to', DB_BACKUP);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// The main file is corrupt or empty.
|
||||
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
|
||||
|
||||
if (fs.existsSync(DB_BACKUP)) {
|
||||
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
|
||||
|
||||
if (isValidSqlite(backup)) {
|
||||
fs.copyFileSync(DB_BACKUP, DB_FILE);
|
||||
console.warn('[DB] Restored database from backup', DB_BACKUP);
|
||||
|
||||
return backup;
|
||||
}
|
||||
|
||||
console.error('[DB] Backup is also invalid - starting with a fresh database');
|
||||
} else {
|
||||
console.error('[DB] No backup available - starting with a fresh database');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
||||
return {
|
||||
locateFile: (file) => {
|
||||
@@ -103,23 +35,6 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the database to disk atomically: write a temp file first,
|
||||
* then rename it over the real file. rename() is atomic on the same
|
||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||
*/
|
||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
||||
|
||||
try {
|
||||
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||
await fsp.rename(tmpPath, DB_FILE);
|
||||
} catch (err) {
|
||||
await fsp.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDataSource(): DataSource {
|
||||
if (!applicationDataSource?.isInitialized) {
|
||||
throw new Error('DataSource not initialised');
|
||||
@@ -132,7 +47,10 @@ export async function initDatabase(): Promise<void> {
|
||||
if (!fs.existsSync(DATA_DIR))
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const database = safeguardDbFile();
|
||||
let database: Uint8Array | undefined;
|
||||
|
||||
if (fs.existsSync(DB_FILE))
|
||||
database = fs.readFileSync(DB_FILE);
|
||||
|
||||
try {
|
||||
applicationDataSource = new DataSource({
|
||||
@@ -155,7 +73,7 @@ export async function initDatabase(): Promise<void> {
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
logging: false,
|
||||
autoSave: true,
|
||||
autoSaveCallback: atomicSave,
|
||||
location: DB_FILE,
|
||||
sqlJsConfig: resolveSqlJsConfig()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -176,7 +94,7 @@ export async function initDatabase(): Promise<void> {
|
||||
await applicationDataSource.runMigrations();
|
||||
console.log('[DB] Migrations executed');
|
||||
} else {
|
||||
console.log('[DB] Synchronize mode - migrations skipped');
|
||||
console.log('[DB] Synchronize mode — migrations skipped');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { resolveCertificateDirectory, resolveEnvFilePath } from './runtime-paths
|
||||
// Load .env from project root (one level up from server/)
|
||||
dotenv.config({ path: resolveEnvFilePath() });
|
||||
|
||||
import { initDatabase, destroyDatabase } from './db/database';
|
||||
import { initDatabase } from './db/database';
|
||||
import { deleteStaleJoinRequests } from './cqrs';
|
||||
import { createApp } from './app';
|
||||
import {
|
||||
@@ -59,9 +59,6 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
||||
return createHttpServer(app);
|
||||
}
|
||||
|
||||
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const variablesConfig = ensureVariablesConfig();
|
||||
const serverProtocol = getServerProtocol();
|
||||
@@ -89,12 +86,10 @@ async function bootstrap(): Promise<void> {
|
||||
const app = createApp();
|
||||
const server = buildServer(app, serverProtocol);
|
||||
|
||||
listeningServer = server;
|
||||
|
||||
setupWebSocket(server);
|
||||
|
||||
// Periodically clean up stale join requests (older than 24 h)
|
||||
staleJoinRequestInterval = setInterval(() => {
|
||||
setInterval(() => {
|
||||
deleteStaleJoinRequests(24 * 60 * 60 * 1000)
|
||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||
}, 60 * 1000);
|
||||
@@ -124,45 +119,6 @@ async function bootstrap(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function gracefulShutdown(signal: string): Promise<void> {
|
||||
if (shuttingDown)
|
||||
return;
|
||||
|
||||
shuttingDown = true;
|
||||
|
||||
if (staleJoinRequestInterval) {
|
||||
clearInterval(staleJoinRequestInterval);
|
||||
staleJoinRequestInterval = null;
|
||||
}
|
||||
|
||||
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
||||
|
||||
if (listeningServer?.listening) {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
listeningServer?.close(() => resolve());
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Shutdown] Error closing server:', err);
|
||||
}
|
||||
}
|
||||
|
||||
listeningServer = null;
|
||||
|
||||
try {
|
||||
await destroyDatabase();
|
||||
} catch (err) {
|
||||
console.error('[Shutdown] Error closing database:', err);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -4,11 +4,7 @@ export class ServerChannels1000000000002 implements MigrationInterface {
|
||||
name = 'ServerChannels1000000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const columns: { name: string }[] = await queryRunner.query(`PRAGMA table_info("servers")`);
|
||||
const hasChannels = columns.some(c => c.name === 'channels');
|
||||
if (!hasChannels) {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable complexity */
|
||||
import { Router } from 'express';
|
||||
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
||||
|
||||
@@ -46,11 +47,6 @@ interface KlipyApiResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedGifMedia {
|
||||
previewMeta: NormalizedMediaMeta | null;
|
||||
sourceMeta: NormalizedMediaMeta;
|
||||
}
|
||||
|
||||
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
||||
for (const value of values) {
|
||||
if (value != null)
|
||||
@@ -134,49 +130,33 @@ function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null {
|
||||
const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd);
|
||||
const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs);
|
||||
const previewMeta = pickGifMeta(previewVariant);
|
||||
const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta;
|
||||
|
||||
if (!sourceMeta?.url)
|
||||
return null;
|
||||
|
||||
return {
|
||||
previewMeta,
|
||||
sourceMeta
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGifSlug(gifItem: KlipyGifItem): string | undefined {
|
||||
return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
||||
}
|
||||
|
||||
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
||||
if (!item || typeof item !== 'object')
|
||||
return null;
|
||||
|
||||
const gifItem = item as KlipyGifItem;
|
||||
const resolvedMedia = resolveGifMedia(gifItem.file);
|
||||
const slug = resolveGifSlug(gifItem);
|
||||
|
||||
if (gifItem.type === 'ad')
|
||||
return null;
|
||||
|
||||
if (!slug || !resolvedMedia)
|
||||
return null;
|
||||
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd);
|
||||
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
|
||||
const lowMeta = pickGifMeta(lowVariant);
|
||||
const highMeta = pickGifMeta(highVariant);
|
||||
const selectedMeta = highMeta ?? lowMeta;
|
||||
const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
||||
|
||||
const { previewMeta, sourceMeta } = resolvedMedia;
|
||||
if (!slug || !selectedMeta?.url)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: slug,
|
||||
slug,
|
||||
title: sanitizeString(gifItem.title),
|
||||
url: sourceMeta.url,
|
||||
previewUrl: previewMeta?.url ?? sourceMeta.url,
|
||||
width: sourceMeta.width ?? previewMeta?.width ?? 0,
|
||||
height: sourceMeta.height ?? previewMeta?.height ?? 0
|
||||
url: selectedMeta.url,
|
||||
previewUrl: lowMeta?.url ?? selectedMeta.url,
|
||||
width: selectedMeta.width ?? lowMeta?.width ?? 0,
|
||||
height: selectedMeta.height ?? lowMeta?.height ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { connectedUsers } from './state';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
import { ConnectedUser } from './types';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
vi.mock('../services/server-access.service', () => ({
|
||||
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
||||
}));
|
||||
|
||||
/**
|
||||
* Minimal mock WebSocket that records sent messages.
|
||||
*/
|
||||
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => {},
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[] };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
oderId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const ws = createMockWs();
|
||||
const user: ConnectedUser = {
|
||||
oderId,
|
||||
ws,
|
||||
serverIds: new Set(),
|
||||
displayName: 'Test User',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function getRequiredConnectedUser(connectionId: string): ConnectedUser {
|
||||
const connectedUser = connectedUsers.get(connectionId);
|
||||
|
||||
if (!connectedUser)
|
||||
throw new Error(`Expected connected user for ${connectionId}`);
|
||||
|
||||
return connectedUser;
|
||||
}
|
||||
|
||||
function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } {
|
||||
return user.ws as unknown as { sentMessages: string[] };
|
||||
}
|
||||
|
||||
describe('server websocket handler - status_update', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('updates user status on valid status_update message', async () => {
|
||||
const user = createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
user.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBe('away');
|
||||
});
|
||||
|
||||
it('broadcasts status_update to other users in the same server', async () => {
|
||||
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
user1.serverIds.add('server-1');
|
||||
user2.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
|
||||
|
||||
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update');
|
||||
|
||||
expect(statusMsg).toBeDefined();
|
||||
expect(statusMsg?.oderId).toBe('user-1');
|
||||
expect(statusMsg?.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('does not broadcast to users in different servers', async () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
getRequiredConnectedUser('conn-1').serverIds.add('server-1');
|
||||
user2.serverIds.add('server-2');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||
|
||||
expect(getSentMessagesStore(user2).sentMessages.length).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores invalid status values', async () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'invalid_status' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores missing status field', async () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts all valid status values', async () => {
|
||||
for (const status of [
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
]) {
|
||||
connectedUsers.clear();
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes status in server_users response after status change', async () => {
|
||||
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
user1.serverIds.add('server-1');
|
||||
user2.serverIds.add('server-1');
|
||||
|
||||
// Set user-1 to away
|
||||
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
|
||||
|
||||
// Clear sent messages
|
||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||
|
||||
// Identify first (required for handler)
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||
|
||||
// user-2 joins server → should receive server_users with user-1's status
|
||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
|
||||
|
||||
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||
|
||||
expect(serverUsersMsg).toBeDefined();
|
||||
|
||||
const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||
|
||||
expect(user1InList?.status).toBe('away');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server websocket handler - user_joined includes status', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('includes status in user_joined broadcast', async () => {
|
||||
const user1 = createConnectedUser('conn-1', 'user-1');
|
||||
const user2 = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
user1.serverIds.add('server-1');
|
||||
user2.serverIds.add('server-1');
|
||||
|
||||
// Set user-1's status to busy before joining
|
||||
getRequiredConnectedUser('conn-1').status = 'busy';
|
||||
|
||||
// Identify user-1
|
||||
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
|
||||
|
||||
getSentMessagesStore(user2).sentMessages.length = 0;
|
||||
|
||||
// user-1 joins server-1
|
||||
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||
|
||||
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||
|
||||
// user_joined may or may not appear depending on whether it's a new identity membership
|
||||
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
|
||||
if (joinMsg) {
|
||||
expect(joinMsg.status).toBe('busy');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('server websocket handler - profile metadata in presence messages', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('broadcasts updated profile metadata when an identified user changes it', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'user-1', {
|
||||
displayName: 'Alice',
|
||||
viewedServerId: 'server-1'
|
||||
});
|
||||
const bob = createConnectedUser('conn-2', 'user-2', {
|
||||
viewedServerId: 'server-1'
|
||||
});
|
||||
|
||||
alice.serverIds.add('server-1');
|
||||
bob.serverIds.add('server-1');
|
||||
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice Updated',
|
||||
description: 'Updated bio',
|
||||
profileUpdatedAt: 789
|
||||
});
|
||||
|
||||
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||
|
||||
expect(joinMsg?.displayName).toBe('Alice Updated');
|
||||
expect(joinMsg?.description).toBe('Updated bio');
|
||||
expect(joinMsg?.profileUpdatedAt).toBe(789);
|
||||
expect(joinMsg?.serverId).toBe('server-1');
|
||||
});
|
||||
|
||||
it('includes description and profileUpdatedAt in server_users responses', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'user-1');
|
||||
const bob = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
alice.serverIds.add('server-1');
|
||||
bob.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
description: 'Alice bio',
|
||||
profileUpdatedAt: 123
|
||||
});
|
||||
|
||||
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-2', {
|
||||
type: 'view_server',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||
const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||
|
||||
expect(aliceInList?.description).toBe('Alice bio');
|
||||
expect(aliceInList?.profileUpdatedAt).toBe(123);
|
||||
});
|
||||
|
||||
it('includes description and profileUpdatedAt in user_joined broadcasts', async () => {
|
||||
const bob = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
bob.serverIds.add('server-1');
|
||||
bob.viewedServerId = 'server-1';
|
||||
|
||||
createConnectedUser('conn-1', 'user-1', {
|
||||
displayName: 'Alice',
|
||||
description: 'Alice bio',
|
||||
profileUpdatedAt: 456,
|
||||
viewedServerId: 'server-1'
|
||||
});
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'join_server',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
|
||||
|
||||
expect(joinMsg?.description).toBe('Alice bio');
|
||||
expect(joinMsg?.profileUpdatedAt).toBe(456);
|
||||
});
|
||||
});
|
||||
@@ -20,22 +20,6 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function normalizeDescription(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
@@ -53,13 +37,7 @@ function readMessageId(value: unknown): string | undefined {
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
.map(cu => ({
|
||||
oderId: cu.oderId,
|
||||
displayName: normalizeDisplayName(cu.displayName),
|
||||
description: cu.description,
|
||||
profileUpdatedAt: cu.profileUpdatedAt,
|
||||
status: cu.status ?? 'online'
|
||||
}));
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
@@ -67,9 +45,6 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||
const previousDescription = user.description;
|
||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||
|
||||
// Close stale connections from the same identity AND the same connection
|
||||
// scope so offer routing always targets the freshest socket (e.g. after
|
||||
@@ -92,38 +67,9 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||
user.description = normalizeDescription(message['description']);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) {
|
||||
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
|
||||
}
|
||||
|
||||
user.connectionScope = newScope;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
|
||||
if (
|
||||
user.displayName === previousDisplayName
|
||||
&& user.description === previousDescription
|
||||
&& user.profileUpdatedAt === previousProfileUpdatedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const serverId of user.serverIds) {
|
||||
broadcastToServer(serverId, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
serverId
|
||||
}, user.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
@@ -162,9 +108,6 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
}
|
||||
@@ -261,32 +204,6 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_STATUSES = new Set([
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
]);
|
||||
|
||||
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
||||
|
||||
if (!status || !VALID_STATUSES.has(status))
|
||||
return;
|
||||
|
||||
user.status = status as ConnectedUser['status'];
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status → ${status}`);
|
||||
|
||||
for (const serverId of user.serverIds) {
|
||||
broadcastToServer(serverId, {
|
||||
type: 'status_update',
|
||||
oderId: user.oderId,
|
||||
status
|
||||
}, user.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
@@ -324,10 +241,6 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
handleTyping(user, message);
|
||||
break;
|
||||
|
||||
case 'status_update':
|
||||
handleStatusUpdate(user, message, connectionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ export interface ConnectedUser {
|
||||
serverIds: Set<string>;
|
||||
viewedServerId?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
/**
|
||||
* Opaque scope string sent by the client (typically the signal URL it
|
||||
* connected through). Stale-connection eviction only targets connections
|
||||
@@ -15,8 +13,6 @@ export interface ConnectedUser {
|
||||
* URLs routing to the same server coexist without an eviction loop.
|
||||
*/
|
||||
connectionScope?: string;
|
||||
/** User availability status (online, away, busy, offline). */
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.32MB"
|
||||
"maximumError": "2.3MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -16,7 +16,6 @@ import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||
import { NotificationsEffects } from './domains/notifications';
|
||||
import { MessagesEffects } from './store/messages/messages.effects';
|
||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
||||
import { UsersEffects } from './store/users/users.effects';
|
||||
import { RoomsEffects } from './store/rooms/rooms.effects';
|
||||
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
|
||||
@@ -39,7 +38,6 @@ export const appConfig: ApplicationConfig = {
|
||||
NotificationsEffects,
|
||||
MessagesEffects,
|
||||
MessagesSyncEffects,
|
||||
UserAvatarEffects,
|
||||
UsersEffects,
|
||||
RoomsEffects,
|
||||
RoomMembersSyncEffects,
|
||||
|
||||
@@ -33,14 +33,13 @@ import { VoiceSessionFacade } from './domains/voice-session';
|
||||
import { ExternalLinkService } from './core/platform';
|
||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||
import { UserStatusService } from './core/services/user-status.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
@@ -93,7 +92,6 @@ export class App implements OnInit, OnDestroy {
|
||||
readonly voiceSession = inject(VoiceSessionFacade);
|
||||
readonly externalLinks = inject(ExternalLinkService);
|
||||
readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly userStatus = inject(UserStatusService);
|
||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||
@@ -161,7 +159,7 @@ export class App implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
void import('./domains/theme/feature/settings/theme-settings/theme-settings.component')
|
||||
void import('./domains/theme/feature/settings/theme-settings.component')
|
||||
.then((module) => {
|
||||
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
||||
});
|
||||
@@ -233,8 +231,6 @@ export class App implements OnInit, OnDestroy {
|
||||
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
|
||||
this.userStatus.start();
|
||||
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,3 @@ export * from './notification-audio.service';
|
||||
export * from '../models/debugging.models';
|
||||
export * from './debugging/debugging.service';
|
||||
export * from './settings-modal.service';
|
||||
export * from './user-status.service';
|
||||
|
||||
@@ -41,9 +41,6 @@ export class NotificationAudioService {
|
||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||
readonly notificationVolume = signal(this.loadVolume());
|
||||
|
||||
/** When true, all sound playback is suppressed (Do Not Disturb). */
|
||||
readonly dndMuted = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.preload();
|
||||
}
|
||||
@@ -109,9 +106,6 @@ export class NotificationAudioService {
|
||||
* the persisted {@link notificationVolume} is used.
|
||||
*/
|
||||
play(sound: AppSound, volumeOverride?: number): void {
|
||||
if (this.dndMuted())
|
||||
return;
|
||||
|
||||
const cached = this.cache.get(sound);
|
||||
const src = this.sources.get(sound);
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnDestroy,
|
||||
NgZone,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
import { selectManualStatus, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { RealtimeSessionFacade } from '../realtime';
|
||||
import { NotificationAudioService } from './notification-audio.service';
|
||||
import { UserStatus } from '../../shared-kernel';
|
||||
|
||||
const BROWSER_IDLE_POLL_MS = 10_000;
|
||||
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
interface ElectronIdleApi {
|
||||
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||
getIdleState: () => Promise<'active' | 'idle'>;
|
||||
}
|
||||
|
||||
type IdleAwareWindow = Window & {
|
||||
electronAPI?: ElectronIdleApi;
|
||||
};
|
||||
|
||||
/**
|
||||
* Orchestrates user status based on idle detection (Electron powerMonitor
|
||||
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
|
||||
*
|
||||
* Manual status always takes priority over automatic idle detection.
|
||||
* When manual status is cleared, the service falls back to automatic.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserStatusService implements OnDestroy {
|
||||
private store = inject(Store);
|
||||
private zone = inject(NgZone);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private audio = inject(NotificationAudioService);
|
||||
private readonly manualStatus = this.store.selectSignal(selectManualStatus);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
private electronCleanup: (() => void) | null = null;
|
||||
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastActivityTimestamp = Date.now();
|
||||
private browserActivityListeners: (() => void)[] = [];
|
||||
private currentAutoStatus: UserStatus = 'online';
|
||||
private started = false;
|
||||
|
||||
start(): void {
|
||||
if (this.started)
|
||||
return;
|
||||
|
||||
this.started = true;
|
||||
|
||||
if (this.getElectronIdleApi()?.onIdleStateChanged) {
|
||||
this.startElectronIdleDetection();
|
||||
} else {
|
||||
this.startBrowserIdleDetection();
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a manual status override (e.g. DND = 'busy'). Pass `null` to clear. */
|
||||
setManualStatus(status: UserStatus | null): void {
|
||||
this.store.dispatch(UsersActions.setManualStatus({ status }));
|
||||
this.audio.dndMuted.set(status === 'busy');
|
||||
this.broadcastStatus(this.resolveEffectiveStatus(status));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.electronCleanup?.();
|
||||
this.electronCleanup = null;
|
||||
|
||||
if (this.browserPollTimer) {
|
||||
clearInterval(this.browserPollTimer);
|
||||
this.browserPollTimer = null;
|
||||
}
|
||||
|
||||
for (const remove of this.browserActivityListeners) {
|
||||
remove();
|
||||
}
|
||||
|
||||
this.browserActivityListeners = [];
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
private startElectronIdleDetection(): void {
|
||||
const api = this.getElectronIdleApi();
|
||||
|
||||
if (!api)
|
||||
return;
|
||||
|
||||
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
|
||||
this.zone.run(() => {
|
||||
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
||||
this.applyAutoStatusIfAllowed();
|
||||
});
|
||||
});
|
||||
|
||||
// Check initial state
|
||||
api.getIdleState().then((idleState: 'active' | 'idle') => {
|
||||
this.zone.run(() => {
|
||||
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
||||
this.applyAutoStatusIfAllowed();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private startBrowserIdleDetection(): void {
|
||||
this.lastActivityTimestamp = Date.now();
|
||||
|
||||
const onActivity = () => {
|
||||
this.lastActivityTimestamp = Date.now();
|
||||
const wasAway = this.currentAutoStatus === 'away';
|
||||
|
||||
if (wasAway) {
|
||||
this.currentAutoStatus = 'online';
|
||||
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
||||
}
|
||||
};
|
||||
const events = [
|
||||
'mousemove',
|
||||
'keydown',
|
||||
'mousedown',
|
||||
'touchstart',
|
||||
'scroll'
|
||||
] as const;
|
||||
|
||||
for (const evt of events) {
|
||||
document.addEventListener(evt, onActivity, { passive: true });
|
||||
this.browserActivityListeners.push(() =>
|
||||
document.removeEventListener(evt, onActivity)
|
||||
);
|
||||
}
|
||||
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.browserPollTimer = setInterval(() => {
|
||||
const idle = Date.now() - this.lastActivityTimestamp >= BROWSER_IDLE_THRESHOLD_MS;
|
||||
|
||||
if (idle && this.currentAutoStatus !== 'away') {
|
||||
this.currentAutoStatus = 'away';
|
||||
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
||||
}
|
||||
}, BROWSER_IDLE_POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private applyAutoStatusIfAllowed(): void {
|
||||
const manualStatus = this.manualStatus();
|
||||
|
||||
// Manual status overrides automatic
|
||||
if (manualStatus)
|
||||
return;
|
||||
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (currentUser?.status !== this.currentAutoStatus) {
|
||||
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
|
||||
this.store.dispatch(UsersActions.updateCurrentUser({ updates: { status: this.currentAutoStatus } }));
|
||||
this.broadcastStatus(this.currentAutoStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEffectiveStatus(manualStatus: UserStatus | null): UserStatus {
|
||||
return manualStatus ?? this.currentAutoStatus;
|
||||
}
|
||||
|
||||
private broadcastStatus(status: UserStatus): void {
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'status_update',
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
private getElectronIdleApi(): ElectronIdleApi | undefined {
|
||||
return (window as IdleAwareWindow).electronAPI;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ infrastructure adapters and UI.
|
||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||
@@ -29,7 +28,6 @@ The larger domains also keep longer design notes in their own folders:
|
||||
- [authentication/README.md](authentication/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||
- [screen-share/README.md](screen-share/README.md)
|
||||
- [server-directory/README.md](server-directory/README.md)
|
||||
- [voice-connection/README.md](voice-connection/README.md)
|
||||
|
||||
@@ -3,11 +3,6 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
|
||||
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
|
||||
import {
|
||||
arrayBufferToBase64,
|
||||
decodeBase64,
|
||||
iterateBlobChunks
|
||||
} from '../../../../shared-kernel';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferTransportService {
|
||||
@@ -15,7 +10,14 @@ export class AttachmentTransferTransportService {
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
|
||||
decodeBase64(base64: string): Uint8Array {
|
||||
return decodeBase64(base64);
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async streamFileToPeer(
|
||||
@@ -25,20 +27,31 @@ export class AttachmentTransferTransportService {
|
||||
file: File,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) {
|
||||
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
while (offset < file.size) {
|
||||
if (isCancelled())
|
||||
break;
|
||||
|
||||
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
||||
const arrayBuffer = await slice.arrayBuffer();
|
||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||
const fileChunkEvent: FileChunkEvent = {
|
||||
type: 'file-chunk',
|
||||
messageId,
|
||||
fileId,
|
||||
index: chunk.index,
|
||||
total: chunk.total,
|
||||
data: chunk.base64
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
|
||||
offset += FILE_CHUNK_SIZE_BYTES;
|
||||
chunkIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +67,7 @@ export class AttachmentTransferTransportService {
|
||||
if (!base64Full)
|
||||
return;
|
||||
|
||||
const fileBytes = decodeBase64(base64Full);
|
||||
const fileBytes = this.decodeBase64(base64Full);
|
||||
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
@@ -68,7 +81,7 @@ export class AttachmentTransferTransportService {
|
||||
slice.byteOffset,
|
||||
slice.byteOffset + slice.byteLength
|
||||
);
|
||||
const base64Chunk = arrayBufferToBase64(sliceBuffer);
|
||||
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
||||
const fileChunkEvent: FileChunkEvent = {
|
||||
type: 'file-chunk',
|
||||
messageId,
|
||||
@@ -81,4 +94,16 @@ export class AttachmentTransferTransportService {
|
||||
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
for (let index = 0; index < bytes.byteLength; index++) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants';
|
||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
||||
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
||||
|
||||
/**
|
||||
* EWMA smoothing weight for the previous speed estimate.
|
||||
|
||||
@@ -1,45 +1,35 @@
|
||||
<div class="w-full border-t border-border bg-card/50 px-1 py-2">
|
||||
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
|
||||
<div class="flex-1"></div>
|
||||
@if (user()) {
|
||||
<div class="flex flex-col items-center gap-1 text-xs">
|
||||
<button
|
||||
#avatarBtn
|
||||
type="button"
|
||||
class="rounded-full transition-opacity hover:opacity-90"
|
||||
(click)="toggleProfileCard(avatarBtn)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="user()!.displayName"
|
||||
[avatarUrl]="user()!.avatarUrl"
|
||||
size="sm"
|
||||
[status]="user()!.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<ng-icon
|
||||
name="lucideUser"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('login')"
|
||||
class="w-full px-1 py-1 text-[10px] rounded bg-secondary hover:bg-secondary/80 flex items-center justify-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('register')"
|
||||
class="w-full px-1 py-1 text-[10px] rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center justify-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('login')"
|
||||
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('register')"
|
||||
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Register
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -3,21 +3,19 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideUser,
|
||||
lucideLogIn,
|
||||
lucideUserPlus
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
provideIcons({ lucideUser,
|
||||
lucideLogIn,
|
||||
lucideUserPlus })
|
||||
],
|
||||
@@ -31,16 +29,6 @@ export class UserBarComponent {
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
private router = inject(Router);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
|
||||
toggleProfileCard(origin: HTMLElement): void {
|
||||
const user = this.user();
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
this.profileCard.open(origin, user, { placement: 'above', editable: true });
|
||||
}
|
||||
|
||||
/** Navigate to the specified authentication page. */
|
||||
goto(path: 'login' | 'register') {
|
||||
|
||||
@@ -12,7 +12,7 @@ export class LinkMetadataService {
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
extractUrls(content: string): string[] {
|
||||
return [...content.matchAll(URL_PATTERN)].map((match) => match[0]);
|
||||
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
||||
}
|
||||
|
||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
export type SpotifyResourceType = 'album' | 'artist' | 'episode' | 'playlist' | 'show' | 'track';
|
||||
export type SoundcloudResourceType = 'playlist' | 'track';
|
||||
|
||||
export interface SpotifyResource {
|
||||
type: SpotifyResourceType;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SoundcloudResource {
|
||||
canonicalUrl: string;
|
||||
type: SoundcloudResourceType;
|
||||
}
|
||||
|
||||
const SPOTIFY_RESOURCE_TYPES = new Set<SpotifyResourceType>([
|
||||
'album',
|
||||
'artist',
|
||||
'episode',
|
||||
'playlist',
|
||||
'show',
|
||||
'track'
|
||||
]);
|
||||
const SPOTIFY_URI_PATTERN = /^spotify:(album|artist|episode|playlist|show|track):([a-zA-Z0-9]+)$/i;
|
||||
const SOUNDCLOUD_HOST_PATTERN = /^(?:www\.|m\.)?soundcloud\.com$/i;
|
||||
const YOUTUBE_HOST_PATTERN = /^(?:www\.|m\.|music\.)?youtube\.com$/i;
|
||||
const YOUTU_BE_HOST_PATTERN = /^(?:www\.)?youtu\.be$/i;
|
||||
const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/;
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractYoutubeVideoId(url: string): string | null {
|
||||
const parsedUrl = parseUrl(url);
|
||||
|
||||
if (!parsedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (YOUTU_BE_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||
const shortId = parsedUrl.pathname.split('/').filter(Boolean)[0] ?? '';
|
||||
|
||||
return YOUTUBE_VIDEO_ID_PATTERN.test(shortId) ? shortId : null;
|
||||
}
|
||||
|
||||
if (!YOUTUBE_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (parsedUrl.pathname === '/watch') {
|
||||
const queryId = parsedUrl.searchParams.get('v') ?? '';
|
||||
|
||||
return YOUTUBE_VIDEO_ID_PATTERN.test(queryId) ? queryId : null;
|
||||
}
|
||||
|
||||
if (pathSegments.length >= 2 && (pathSegments[0] === 'embed' || pathSegments[0] === 'shorts')) {
|
||||
const pathId = pathSegments[1];
|
||||
|
||||
return YOUTUBE_VIDEO_ID_PATTERN.test(pathId) ? pathId : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isYoutubeUrl(url?: string): boolean {
|
||||
return !!url && extractYoutubeVideoId(url) !== null;
|
||||
}
|
||||
|
||||
export function extractSpotifyResource(url: string): SpotifyResource | null {
|
||||
const spotifyUriMatch = url.match(SPOTIFY_URI_PATTERN);
|
||||
|
||||
if (spotifyUriMatch?.[1] && spotifyUriMatch[2]) {
|
||||
return {
|
||||
type: spotifyUriMatch[1].toLowerCase() as SpotifyResourceType,
|
||||
id: spotifyUriMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
const parsedUrl = parseUrl(url);
|
||||
|
||||
if (!parsedUrl || !/^(?:open|play)\.spotify\.com$/i.test(parsedUrl.hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (segments.length >= 2 && SPOTIFY_RESOURCE_TYPES.has(segments[0] as SpotifyResourceType)) {
|
||||
return {
|
||||
type: segments[0] as SpotifyResourceType,
|
||||
id: segments[1]
|
||||
};
|
||||
}
|
||||
|
||||
if (segments.length >= 4 && segments[0] === 'user' && segments[2] === 'playlist') {
|
||||
return {
|
||||
type: 'playlist',
|
||||
id: segments[3]
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSpotifyUrl(url?: string): boolean {
|
||||
return !!url && extractSpotifyResource(url) !== null;
|
||||
}
|
||||
|
||||
export function extractSoundcloudResource(url: string): SoundcloudResource | null {
|
||||
const parsedUrl = parseUrl(url);
|
||||
|
||||
if (!parsedUrl || !SOUNDCLOUD_HOST_PATTERN.test(parsedUrl.hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (segments.length === 2) {
|
||||
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/${segments[1]}`);
|
||||
|
||||
return {
|
||||
canonicalUrl: canonicalUrl.toString(),
|
||||
type: 'track'
|
||||
};
|
||||
}
|
||||
|
||||
if (segments.length === 3 && segments[1] === 'sets') {
|
||||
const canonicalUrl = new URL(`https://soundcloud.com/${segments[0]}/sets/${segments[2]}`);
|
||||
|
||||
return {
|
||||
canonicalUrl: canonicalUrl.toString(),
|
||||
type: 'playlist'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSoundcloudUrl(url?: string): boolean {
|
||||
return !!url && extractSoundcloudResource(url) !== null;
|
||||
}
|
||||
|
||||
export function hasDedicatedChatEmbed(url?: string): boolean {
|
||||
return isYoutubeUrl(url) || isSpotifyUrl(url) || isSoundcloudUrl(url);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
import { LinkMetadata } from '../../../../../../../shared-kernel';
|
||||
import { LinkMetadata } from '../../../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-link-embed',
|
||||
@@ -6,16 +6,11 @@
|
||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||
[class.opacity-50]="msg.isDeleted"
|
||||
>
|
||||
<div
|
||||
class="flex-shrink-0 cursor-pointer"
|
||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="senderUser().displayName || msg.senderName"
|
||||
[avatarUrl]="senderUser().avatarUrl"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<app-user-avatar
|
||||
[name]="msg.senderName"
|
||||
size="md"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
@if (msg.replyToId) {
|
||||
@@ -39,11 +34,7 @@
|
||||
}
|
||||
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span
|
||||
class="font-semibold text-foreground cursor-pointer hover:underline"
|
||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||
>{{ msg.senderName }}</span
|
||||
>
|
||||
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
|
||||
@if (msg.editedAt && !msg.isDeleted) {
|
||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||
@@ -100,13 +91,11 @@
|
||||
|
||||
@if (msg.linkMetadata?.length) {
|
||||
@for (meta of msg.linkMetadata; track meta.url) {
|
||||
@if (shouldShowLinkEmbed(meta.url)) {
|
||||
<app-chat-link-embed
|
||||
[metadata]="meta"
|
||||
[canRemove]="isOwnMessage() || isAdmin()"
|
||||
(removed)="removeEmbed(meta.url)"
|
||||
/>
|
||||
}
|
||||
<app-chat-link-embed
|
||||
[metadata]="meta"
|
||||
[canRemove]="isOwnMessage() || isAdmin()"
|
||||
(removed)="removeEmbed(meta.url)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideCheck,
|
||||
@@ -31,21 +30,14 @@ import {
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../attachment';
|
||||
import { KlipyService } from '../../../../application/services/klipy.service';
|
||||
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
Message,
|
||||
User
|
||||
} from '../../../../../../shared-kernel';
|
||||
|
||||
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
ProfileCardService,
|
||||
UserAvatarComponent
|
||||
} from '../../../../../../shared';
|
||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
|
||||
import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.component';
|
||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
||||
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
@@ -122,14 +114,12 @@ export class ChatMessageItemComponent {
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
|
||||
readonly message = input.required<Message>();
|
||||
readonly repliedMessage = input<Message | undefined>();
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly isAdmin = input(false);
|
||||
readonly userLookup = input<ReadonlyMap<string, User>>(new Map());
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
@@ -146,32 +136,9 @@ export class ChatMessageItemComponent {
|
||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||
readonly isEditing = signal(false);
|
||||
readonly showEmojiPicker = signal(false);
|
||||
readonly senderUser = computed<User>(() => {
|
||||
const msg = this.message();
|
||||
const found = this.userLookup().get(msg.senderId);
|
||||
|
||||
return found ?? {
|
||||
id: msg.senderId,
|
||||
oderId: msg.senderId,
|
||||
username: msg.senderName,
|
||||
displayName: msg.senderName,
|
||||
status: 'disconnected',
|
||||
role: 'member',
|
||||
joinedAt: 0
|
||||
};
|
||||
});
|
||||
|
||||
editContent = '';
|
||||
|
||||
openSenderProfileCard(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
const user = this.senderUser();
|
||||
const editable = user.id === this.currentUserId();
|
||||
|
||||
this.profileCard.open(el, user, { editable });
|
||||
}
|
||||
|
||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||
void this.attachmentVersion();
|
||||
|
||||
@@ -279,10 +246,6 @@ export class ChatMessageItemComponent {
|
||||
});
|
||||
}
|
||||
|
||||
shouldShowLinkEmbed(url?: string): boolean {
|
||||
return !hasDedicatedChatEmbed(url);
|
||||
}
|
||||
|
||||
requestReferenceScroll(messageId: string): void {
|
||||
this.referenceRequested.emit(messageId);
|
||||
}
|
||||
|
||||
@@ -45,14 +45,6 @@
|
||||
<div class="block">
|
||||
<app-chat-youtube-embed [url]="node.url" />
|
||||
</div>
|
||||
} @else if (isSpotifyUrl(node.url)) {
|
||||
<div class="block">
|
||||
<app-chat-spotify-embed [url]="node.url" />
|
||||
</div>
|
||||
} @else if (isSoundcloudUrl(node.url)) {
|
||||
<div class="block">
|
||||
<app-chat-soundcloud-embed [url]="node.url" />
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</remark>
|
||||
@@ -5,15 +5,8 @@ import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import {
|
||||
isSoundcloudUrl,
|
||||
isSpotifyUrl,
|
||||
isYoutubeUrl
|
||||
} from '../../../../../domain/rules/link-embed.rules';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
|
||||
import { ChatSoundcloudEmbedComponent } from '../chat-soundcloud-embed/chat-soundcloud-embed.component';
|
||||
import { ChatSpotifyEmbedComponent } from '../chat-spotify-embed/chat-spotify-embed.component';
|
||||
import { ChatYoutubeEmbedComponent } from '../chat-youtube-embed/chat-youtube-embed.component';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
|
||||
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
@@ -47,8 +40,6 @@ const REMARK_PROCESSOR = unified()
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
ChatImageProxyFallbackDirective,
|
||||
ChatSpotifyEmbedComponent,
|
||||
ChatSoundcloudEmbedComponent,
|
||||
ChatYoutubeEmbedComponent
|
||||
],
|
||||
templateUrl: './chat-message-markdown.component.html'
|
||||
@@ -72,14 +63,6 @@ export class ChatMessageMarkdownComponent {
|
||||
return isYoutubeUrl(url);
|
||||
}
|
||||
|
||||
isSpotifyUrl(url?: string): boolean {
|
||||
return isSpotifyUrl(url);
|
||||
}
|
||||
|
||||
isSoundcloudUrl(url?: string): boolean {
|
||||
return isSoundcloudUrl(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
@if (embedUrl(); as soundcloudEmbedUrl) {
|
||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<iframe
|
||||
[src]="soundcloudEmbedUrl"
|
||||
[style.height.px]="embedHeight()"
|
||||
class="w-full border-0"
|
||||
loading="lazy"
|
||||
title="SoundCloud player"
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { extractSoundcloudResource } from '../../../../../domain/rules/link-embed.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-soundcloud-embed',
|
||||
standalone: true,
|
||||
templateUrl: './chat-soundcloud-embed.component.html'
|
||||
})
|
||||
export class ChatSoundcloudEmbedComponent {
|
||||
readonly url = input.required<string>();
|
||||
|
||||
readonly resource = computed(() => extractSoundcloudResource(this.url()));
|
||||
|
||||
readonly embedHeight = computed(() => this.resource()?.type === 'playlist' ? 352 : 166);
|
||||
|
||||
readonly embedUrl = computed(() => {
|
||||
const resource = this.resource();
|
||||
|
||||
if (!resource) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const embedUrl = new URL('https://w.soundcloud.com/player/');
|
||||
|
||||
embedUrl.searchParams.set('url', resource.canonicalUrl);
|
||||
embedUrl.searchParams.set('auto_play', 'false');
|
||||
embedUrl.searchParams.set('hide_related', 'false');
|
||||
embedUrl.searchParams.set('show_comments', 'false');
|
||||
embedUrl.searchParams.set('show_user', 'true');
|
||||
embedUrl.searchParams.set('show_reposts', 'false');
|
||||
embedUrl.searchParams.set('show_teaser', 'true');
|
||||
embedUrl.searchParams.set('visual', resource.type === 'playlist' ? 'true' : 'false');
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
|
||||
});
|
||||
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
@if (embedUrl(); as spotifyEmbedUrl) {
|
||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<iframe
|
||||
[src]="spotifyEmbedUrl"
|
||||
[style.height.px]="embedHeight()"
|
||||
class="w-full border-0"
|
||||
loading="lazy"
|
||||
title="Spotify player"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { extractSpotifyResource } from '../../../../../domain/rules/link-embed.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-spotify-embed',
|
||||
standalone: true,
|
||||
templateUrl: './chat-spotify-embed.component.html'
|
||||
})
|
||||
export class ChatSpotifyEmbedComponent {
|
||||
readonly url = input.required<string>();
|
||||
|
||||
readonly resource = computed(() => extractSpotifyResource(this.url()));
|
||||
|
||||
readonly embedHeight = computed(() => {
|
||||
const resource = this.resource();
|
||||
|
||||
if (!resource) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (resource.type) {
|
||||
case 'track':
|
||||
case 'episode':
|
||||
return 152;
|
||||
default:
|
||||
return 352;
|
||||
}
|
||||
});
|
||||
|
||||
readonly embedUrl = computed(() => {
|
||||
const resource = this.resource();
|
||||
|
||||
if (!resource) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const embedUrl = new URL(`https://open.spotify.com/embed/${resource.type}/${encodeURIComponent(resource.id)}`);
|
||||
|
||||
embedUrl.searchParams.set('utm_source', 'generator');
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
|
||||
});
|
||||
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-youtube-embed',
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (videoId()) {
|
||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
||||
<iframe
|
||||
[src]="embedUrl()"
|
||||
class="aspect-video w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ChatYoutubeEmbedComponent {
|
||||
readonly url = input.required<string>();
|
||||
|
||||
readonly videoId = computed(() => {
|
||||
const match = this.url().match(YOUTUBE_URL_PATTERN);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
});
|
||||
|
||||
readonly embedUrl = computed(() => {
|
||||
const id = this.videoId();
|
||||
|
||||
if (!id)
|
||||
return '';
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(
|
||||
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
|
||||
);
|
||||
});
|
||||
|
||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||
}
|
||||
|
||||
export function isYoutubeUrl(url?: string): boolean {
|
||||
return !!url && YOUTUBE_URL_PATTERN.test(url);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
@if (videoId()) {
|
||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
||||
<iframe
|
||||
[src]="embedUrl()"
|
||||
class="aspect-video w-full"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
|
||||
|
||||
const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app';
|
||||
|
||||
function resolveYoutubeClientOrigin(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return YOUTUBE_EMBED_FALLBACK_ORIGIN;
|
||||
}
|
||||
|
||||
const origin = window.location.origin;
|
||||
|
||||
return /^https?:\/\//.test(origin)
|
||||
? origin
|
||||
: YOUTUBE_EMBED_FALLBACK_ORIGIN;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-youtube-embed',
|
||||
standalone: true,
|
||||
templateUrl: './chat-youtube-embed.component.html'
|
||||
})
|
||||
export class ChatYoutubeEmbedComponent {
|
||||
readonly url = input.required<string>();
|
||||
|
||||
readonly videoId = computed(() => extractYoutubeVideoId(this.url()));
|
||||
|
||||
readonly embedUrl = computed(() => {
|
||||
const id = this.videoId();
|
||||
|
||||
if (!id)
|
||||
return '';
|
||||
|
||||
const clientOrigin = resolveYoutubeClientOrigin();
|
||||
const embedUrl = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`);
|
||||
|
||||
embedUrl.searchParams.set('origin', clientOrigin);
|
||||
embedUrl.searchParams.set('widget_referrer', clientOrigin);
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(
|
||||
embedUrl.toString()
|
||||
);
|
||||
});
|
||||
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
}
|
||||
@@ -53,7 +53,6 @@
|
||||
[repliedMessage]="findRepliedMessage(message.replyToId)"
|
||||
[currentUserId]="currentUserId()"
|
||||
[isAdmin]="isAdmin()"
|
||||
[userLookup]="userLookup()"
|
||||
(replyRequested)="handleReplyRequested($event)"
|
||||
(deleteRequested)="handleDeleteRequested($event)"
|
||||
(editSaved)="handleEditSaved($event)"
|
||||
|
||||
@@ -8,15 +8,13 @@ import {
|
||||
ViewChild,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Attachment } from '../../../../../attachment';
|
||||
import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
|
||||
import { Message, User } from '../../../../../../shared-kernel';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
} from '../../models/chat-messages.model';
|
||||
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
||||
|
||||
interface PrismGlobal {
|
||||
@@ -50,8 +47,6 @@ declare global {
|
||||
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
private readonly store = inject(Store);
|
||||
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
@@ -115,20 +110,6 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
return labels;
|
||||
});
|
||||
|
||||
readonly userLookup = computed<ReadonlyMap<string, User>>(() => {
|
||||
const lookup = new Map<string, User>();
|
||||
|
||||
for (const user of this.allUsers()) {
|
||||
lookup.set(user.id, user);
|
||||
|
||||
if (user.oderId && user.oderId !== user.id) {
|
||||
lookup.set(user.oderId, user);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
});
|
||||
|
||||
private initialScrollObserver: MutationObserver | null = null;
|
||||
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private boundOnImageLoad: (() => void) | null = null;
|
||||
|
||||
@@ -27,14 +27,17 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar with status indicator -->
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
[name]="user.displayName"
|
||||
[status]="user.status"
|
||||
[showStatusBadge]="true"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
@@ -56,16 +59,6 @@
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (user.status && user.status !== 'online') {
|
||||
<span
|
||||
class="text-xs"
|
||||
[class.text-yellow-500]="user.status === 'away'"
|
||||
[class.text-red-500]="user.status === 'busy'"
|
||||
[class.text-muted-foreground]="user.status === 'offline'"
|
||||
>
|
||||
{{ user.status === 'busy' ? 'Do Not Disturb' : (user.status | titlecase) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Voice/Screen Status -->
|
||||
|
||||
@@ -83,7 +83,7 @@ export function shouldDeliverNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.currentUser?.status === 'busy') {
|
||||
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
lucideMessageSquareText,
|
||||
lucideMoonStar
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors';
|
||||
import type { Room } from '../../../../../shared-kernel';
|
||||
import { NotificationsFacade } from '../../../application/facades/notifications.facade';
|
||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
import type { Room } from '../../../../shared-kernel';
|
||||
import { NotificationsFacade } from '../../application/facades/notifications.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notifications-settings',
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './application/facades/notifications.facade';
|
||||
export * from './application/effects/notifications.effects';
|
||||
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings/notifications-settings.component';
|
||||
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# Profile Avatar Domain
|
||||
|
||||
Owns local profile picture workflow plus peer-synced profile-card metadata: source validation, crop/zoom editor state, static 256x256 WebP rendering, animated avatar preservation, desktop file persistence, and P2P avatar/profile sync metadata.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Accept `.webp`, `.gif`, `.jpg`, `.jpeg` profile image sources.
|
||||
- Let user drag and zoom source inside fixed preview frame before saving.
|
||||
- Render static avatars to `256x256` WebP with client-side compression.
|
||||
- Preserve animated `.gif` and animated `.webp` uploads without flattening frames.
|
||||
- Persist desktop copy at `user/<username>/profile/profile.<ext>` under app data.
|
||||
- Let the local user edit their profile-card display name and description.
|
||||
- Expose helpers used by store effects to keep avatar metadata (`avatarHash`, `avatarMime`, `avatarUpdatedAt`) consistent.
|
||||
- Reuse the avatar summary/request/full handshake to sync profile text (`displayName`, `description`, `profileUpdatedAt`) alongside avatar state.
|
||||
|
||||
## Module map
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
PC[ProfileCardComponent] --> PAE[ProfileAvatarEditorComponent]
|
||||
PAE --> PAF[ProfileAvatarFacade]
|
||||
PAF --> PAI[ProfileAvatarImageService]
|
||||
PAF --> PAS[ProfileAvatarStorageService]
|
||||
PAF --> Store[UsersActions.updateCurrentUserAvatar]
|
||||
Store --> UAV[UserAvatarEffects]
|
||||
UAV --> RTC[WebRTC data channel]
|
||||
UAV --> DB[DatabaseService]
|
||||
|
||||
click PAE "feature/profile-avatar-editor/" "Crop and zoom editor UI" _blank
|
||||
click PAF "application/services/profile-avatar.facade.ts" "Facade used by UI and effects" _blank
|
||||
click PAI "infrastructure/services/profile-avatar-image.service.ts" "Canvas render and compression" _blank
|
||||
click PAS "infrastructure/services/profile-avatar-storage.service.ts" "Electron file persistence" _blank
|
||||
```
|
||||
|
||||
## Flow
|
||||
|
||||
1. `ProfileCardComponent` opens file picker from editable avatar button.
|
||||
2. `ProfileCardComponent` saves display-name and description edits through the users store.
|
||||
3. `ProfileAvatarEditorComponent` previews exact crop using drag + zoom.
|
||||
4. `ProfileAvatarImageService` renders static uploads to `256x256` WebP, but keeps animated GIF and WebP sources intact.
|
||||
5. `ProfileAvatarStorageService` writes desktop copy when Electron is available.
|
||||
6. `UserAvatarEffects` broadcasts avatar/profile summaries, answers requests, streams chunks when needed, and persists received profile state locally.
|
||||
|
||||
## Notes
|
||||
|
||||
- Static uploads are normalized to WebP. Animated GIF and animated WebP uploads keep their original animation, mime type, and full-frame presentation.
|
||||
- `avatarUrl` stays local display data. Version conflict resolution uses `avatarUpdatedAt` and `avatarHash`.
|
||||
- Profile text uses its own `profileUpdatedAt` version so display-name and description changes can sync without replacing a newer avatar.
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import {
|
||||
EditableProfileAvatarSource,
|
||||
ProcessedProfileAvatar,
|
||||
ProfileAvatarTransform,
|
||||
ProfileAvatarUpdates
|
||||
} from '../../domain/profile-avatar.models';
|
||||
import { ProfileAvatarImageService } from '../../infrastructure/services/profile-avatar-image.service';
|
||||
import { ProfileAvatarStorageService } from '../../infrastructure/services/profile-avatar-storage.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileAvatarFacade {
|
||||
private readonly image = inject(ProfileAvatarImageService);
|
||||
private readonly storage = inject(ProfileAvatarStorageService);
|
||||
|
||||
validateFile(file: File): string | null {
|
||||
return this.image.validateFile(file);
|
||||
}
|
||||
|
||||
prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
|
||||
return this.image.prepareEditableSource(file);
|
||||
}
|
||||
|
||||
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
|
||||
this.image.releaseEditableSource(source);
|
||||
}
|
||||
|
||||
processEditableSource(
|
||||
source: EditableProfileAvatarSource,
|
||||
transform: ProfileAvatarTransform
|
||||
): Promise<ProcessedProfileAvatar> {
|
||||
return this.image.processEditableSource(source, transform);
|
||||
}
|
||||
|
||||
persistProcessedAvatar(
|
||||
user: Pick<User, 'id' | 'username' | 'displayName'>,
|
||||
avatar: ProcessedProfileAvatar
|
||||
): Promise<void> {
|
||||
return this.storage.persistProcessedAvatar(user, avatar);
|
||||
}
|
||||
|
||||
persistAvatarDataUrl(
|
||||
user: Pick<User, 'id' | 'username' | 'displayName'>,
|
||||
avatarUrl: string | null | undefined
|
||||
): Promise<void> {
|
||||
const mimeMatch = avatarUrl?.match(/^data:([^;]+);base64,/i);
|
||||
const base64 = avatarUrl?.split(',', 2)[1] ?? '';
|
||||
const avatarMime = mimeMatch?.[1]?.toLowerCase() ?? 'image/webp';
|
||||
|
||||
if (!base64) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.storage.persistProcessedAvatar(user, {
|
||||
base64,
|
||||
avatarMime
|
||||
});
|
||||
}
|
||||
|
||||
buildAvatarUpdates(avatar: ProcessedProfileAvatar): ProfileAvatarUpdates {
|
||||
return {
|
||||
avatarUrl: avatar.avatarUrl,
|
||||
avatarHash: avatar.avatarHash,
|
||||
avatarMime: avatar.avatarMime,
|
||||
avatarUpdatedAt: avatar.avatarUpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import {
|
||||
PROFILE_AVATAR_MAX_ZOOM,
|
||||
PROFILE_AVATAR_MIN_ZOOM,
|
||||
clampProfileAvatarTransform,
|
||||
clampProfileAvatarZoom,
|
||||
resolveProfileAvatarStorageFileName,
|
||||
resolveProfileAvatarBaseScale
|
||||
} from './profile-avatar.models';
|
||||
|
||||
describe('profile-avatar models', () => {
|
||||
it('clamps zoom inside allowed range', () => {
|
||||
expect(clampProfileAvatarZoom(0.1)).toBe(PROFILE_AVATAR_MIN_ZOOM);
|
||||
expect(clampProfileAvatarZoom(9)).toBe(PROFILE_AVATAR_MAX_ZOOM);
|
||||
expect(clampProfileAvatarZoom(2.5)).toBe(2.5);
|
||||
});
|
||||
|
||||
it('resolves cover scale for portrait images', () => {
|
||||
expect(resolveProfileAvatarBaseScale({ width: 200, height: 400 }, 224)).toBeCloseTo(1.12);
|
||||
});
|
||||
|
||||
it('clamps transform offsets so image still covers crop frame', () => {
|
||||
const transform = clampProfileAvatarTransform(
|
||||
{ width: 320, height: 240 },
|
||||
{ zoom: 1, offsetX: 500, offsetY: -500 },
|
||||
224
|
||||
);
|
||||
|
||||
expect(transform.offsetX).toBeCloseTo(37.333333, 4);
|
||||
expect(transform.offsetY).toBe(0);
|
||||
});
|
||||
|
||||
it('maps avatar mime types to storage file names', () => {
|
||||
expect(resolveProfileAvatarStorageFileName('image/gif')).toBe('profile.gif');
|
||||
expect(resolveProfileAvatarStorageFileName('image/jpeg')).toBe('profile.jpg');
|
||||
expect(resolveProfileAvatarStorageFileName('image/webp')).toBe('profile.webp');
|
||||
expect(resolveProfileAvatarStorageFileName(undefined)).toBe('profile.webp');
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
export const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/jpeg'
|
||||
] as const;
|
||||
|
||||
export const PROFILE_AVATAR_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg';
|
||||
export const PROFILE_AVATAR_OUTPUT_SIZE = 256;
|
||||
export const PROFILE_AVATAR_EDITOR_FRAME_SIZE = 224;
|
||||
export const PROFILE_AVATAR_MIN_ZOOM = 1;
|
||||
export const PROFILE_AVATAR_MAX_ZOOM = 4;
|
||||
|
||||
export interface ProfileAvatarDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface EditableProfileAvatarSource extends ProfileAvatarDimensions {
|
||||
file: File;
|
||||
objectUrl: string;
|
||||
mime: string;
|
||||
name: string;
|
||||
preservesAnimation: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileAvatarTransform {
|
||||
zoom: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
export interface ProfileAvatarUpdates {
|
||||
avatarUrl: string;
|
||||
avatarHash: string;
|
||||
avatarMime: string;
|
||||
avatarUpdatedAt: number;
|
||||
}
|
||||
|
||||
export interface ProcessedProfileAvatar extends ProfileAvatarUpdates, ProfileAvatarDimensions {
|
||||
base64: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export function resolveProfileAvatarStorageFileName(mime: string | null | undefined): string {
|
||||
switch (mime?.toLowerCase()) {
|
||||
case 'image/gif':
|
||||
return 'profile.gif';
|
||||
|
||||
case 'image/jpeg':
|
||||
case 'image/jpg':
|
||||
return 'profile.jpg';
|
||||
|
||||
default:
|
||||
return 'profile.webp';
|
||||
}
|
||||
}
|
||||
|
||||
export function clampProfileAvatarZoom(zoom: number): number {
|
||||
if (!Number.isFinite(zoom)) {
|
||||
return PROFILE_AVATAR_MIN_ZOOM;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(zoom, PROFILE_AVATAR_MIN_ZOOM), PROFILE_AVATAR_MAX_ZOOM);
|
||||
}
|
||||
|
||||
export function resolveProfileAvatarBaseScale(
|
||||
source: ProfileAvatarDimensions,
|
||||
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
|
||||
): number {
|
||||
return Math.max(frameSize / source.width, frameSize / source.height);
|
||||
}
|
||||
|
||||
export function clampProfileAvatarTransform(
|
||||
source: ProfileAvatarDimensions,
|
||||
transform: ProfileAvatarTransform,
|
||||
frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE
|
||||
): ProfileAvatarTransform {
|
||||
const zoom = clampProfileAvatarZoom(transform.zoom);
|
||||
const renderedWidth = source.width * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
|
||||
const renderedHeight = source.height * resolveProfileAvatarBaseScale(source, frameSize) * zoom;
|
||||
const maxOffsetX = Math.max(0, (renderedWidth - frameSize) / 2);
|
||||
const maxOffsetY = Math.max(0, (renderedHeight - frameSize) / 2);
|
||||
|
||||
return {
|
||||
zoom,
|
||||
offsetX: clampOffset(transform.offsetX, maxOffsetX),
|
||||
offsetY: clampOffset(transform.offsetY, maxOffsetY)
|
||||
};
|
||||
}
|
||||
|
||||
function clampOffset(value: number, maxMagnitude: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nextValue = Math.min(Math.max(value, -maxMagnitude), maxMagnitude);
|
||||
|
||||
return Object.is(nextValue, -0) ? 0 : nextValue;
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
<div
|
||||
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
(keydown.enter)="cancelled.emit(undefined)"
|
||||
(keydown.space)="cancelled.emit(undefined)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close profile image editor"
|
||||
></div>
|
||||
|
||||
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="border-b border-border p-5">
|
||||
<h3 class="text-lg font-semibold text-foreground">Adjust profile picture</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
@if (preservesAnimation()) {
|
||||
Animated GIF and WebP avatars keep their original animation and framing.
|
||||
} @else {
|
||||
Drag image to frame subject. Zoom until preview looks right. Final image saves as 256x256 WebP.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 overflow-y-auto p-5 sm:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-[32px] border border-border bg-secondary/40 shadow-inner touch-none"
|
||||
[style.width.px]="frameSize"
|
||||
[style.height.px]="frameSize"
|
||||
(pointerdown)="onPointerDown($event)"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp($event)"
|
||||
(pointercancel)="onPointerUp($event)"
|
||||
(wheel)="onWheel($event)"
|
||||
>
|
||||
<img
|
||||
[src]="source().objectUrl"
|
||||
[alt]="source().name"
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 max-w-none select-none"
|
||||
[style.transform]="imageTransform()"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 rounded-[32px] ring-1 ring-white/10"></div>
|
||||
<div class="pointer-events-none absolute inset-4 rounded-full border border-white/45 shadow-[0_0_0_999px_rgba(4,8,15,0.58)]"></div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (preservesAnimation()) {
|
||||
Animation and original framing are preserved.
|
||||
} @else {
|
||||
Preview matches saved crop.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">Source</p>
|
||||
<p class="mt-2 truncate text-sm font-medium text-foreground">{{ source().name }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ source().width }} x {{ source().height }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-border bg-secondary/20 p-4"
|
||||
[class.opacity-60]="preservesAnimation()"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Zoom</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (preservesAnimation()) {
|
||||
Animated avatars keep the original frame sequence.
|
||||
} @else {
|
||||
Use wheel or slider.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
|
||||
(click)="zoomBy(-0.12)"
|
||||
[disabled]="preservesAnimation()"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary"
|
||||
(click)="zoomBy(0.12)"
|
||||
[disabled]="preservesAnimation()"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
step="0.01"
|
||||
class="mt-4 w-full accent-primary"
|
||||
[value]="clampedTransform().zoom"
|
||||
(input)="onZoomInput($event)"
|
||||
[disabled]="preservesAnimation()"
|
||||
/>
|
||||
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
@if (preservesAnimation()) {
|
||||
Animated upload detected.
|
||||
} @else {
|
||||
{{ (clampedTransform().zoom * 100).toFixed(0) }}% zoom
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<div class="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{{ errorMessage() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
[disabled]="processing()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
(click)="confirm()"
|
||||
[disabled]="processing()"
|
||||
>
|
||||
{{ processing() ? 'Saving...' : 'Apply picture' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
|
||||
import {
|
||||
EditableProfileAvatarSource,
|
||||
ProcessedProfileAvatar,
|
||||
ProfileAvatarTransform,
|
||||
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
|
||||
clampProfileAvatarTransform,
|
||||
resolveProfileAvatarBaseScale
|
||||
} from '../../domain/profile-avatar.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-avatar-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './profile-avatar-editor.component.html'
|
||||
})
|
||||
export class ProfileAvatarEditorComponent {
|
||||
readonly source = input.required<EditableProfileAvatarSource>();
|
||||
|
||||
readonly cancelled = output<undefined>();
|
||||
readonly confirmed = output<ProcessedProfileAvatar>();
|
||||
|
||||
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||
readonly processing = signal(false);
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
|
||||
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0 });
|
||||
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
|
||||
readonly imageTransform = computed(() => {
|
||||
const source = this.source();
|
||||
const transform = this.clampedTransform();
|
||||
const scale = resolveProfileAvatarBaseScale(source, this.frameSize) * transform.zoom;
|
||||
|
||||
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
|
||||
});
|
||||
|
||||
private readonly avatar = inject(ProfileAvatarFacade);
|
||||
private dragPointerId: number | null = null;
|
||||
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (!this.processing()) {
|
||||
this.cancelled.emit(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
onZoomChange(value: string): void {
|
||||
if (this.preservesAnimation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoom = Number(value);
|
||||
|
||||
this.transform.update((current) => ({
|
||||
...current,
|
||||
zoom
|
||||
}));
|
||||
}
|
||||
|
||||
onZoomInput(event: Event): void {
|
||||
this.onZoomChange((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
zoomBy(delta: number): void {
|
||||
if (this.preservesAnimation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transform.update((current) => ({
|
||||
...current,
|
||||
zoom: current.zoom + delta
|
||||
}));
|
||||
}
|
||||
|
||||
onWheel(event: WheelEvent): void {
|
||||
if (this.preservesAnimation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.zoomBy(event.deltaY < 0 ? 0.08 : -0.08);
|
||||
}
|
||||
|
||||
onPointerDown(event: PointerEvent): void {
|
||||
if (this.processing() || this.preservesAnimation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTarget = event.currentTarget as HTMLElement | null;
|
||||
const currentTransform = this.clampedTransform();
|
||||
|
||||
currentTarget?.setPointerCapture(event.pointerId);
|
||||
this.dragPointerId = event.pointerId;
|
||||
this.dragOrigin = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
offsetX: currentTransform.offsetX,
|
||||
offsetY: currentTransform.offsetY
|
||||
};
|
||||
}
|
||||
|
||||
onPointerMove(event: PointerEvent): void {
|
||||
if (this.dragPointerId !== event.pointerId || !this.dragOrigin || this.processing() || this.preservesAnimation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transform.set(clampProfileAvatarTransform(this.source(), {
|
||||
zoom: this.clampedTransform().zoom,
|
||||
offsetX: this.dragOrigin.offsetX + (event.clientX - this.dragOrigin.x),
|
||||
offsetY: this.dragOrigin.offsetY + (event.clientY - this.dragOrigin.y)
|
||||
}));
|
||||
}
|
||||
|
||||
onPointerUp(event: PointerEvent): void {
|
||||
if (this.dragPointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTarget = event.currentTarget as HTMLElement | null;
|
||||
|
||||
currentTarget?.releasePointerCapture(event.pointerId);
|
||||
this.dragPointerId = null;
|
||||
this.dragOrigin = null;
|
||||
}
|
||||
|
||||
async confirm(): Promise<void> {
|
||||
if (this.processing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
try {
|
||||
const avatar = await this.avatar.processEditableSource(this.source(), this.clampedTransform());
|
||||
|
||||
this.confirmed.emit(avatar);
|
||||
} catch {
|
||||
this.errorMessage.set('Failed to process profile image.');
|
||||
} finally {
|
||||
this.processing.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||
import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import { EditableProfileAvatarSource, ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
||||
import { ProfileAvatarEditorComponent } from './profile-avatar-editor.component';
|
||||
|
||||
export const PROFILE_AVATAR_EDITOR_OVERLAY_CLASS = 'profile-avatar-editor-overlay-pane';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileAvatarEditorService {
|
||||
private readonly overlay = inject(Overlay);
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
|
||||
open(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar | null> {
|
||||
this.close();
|
||||
|
||||
this.syncThemeVars();
|
||||
|
||||
const overlayRef = this.overlay.create({
|
||||
disposeOnNavigation: true,
|
||||
panelClass: PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||
positionStrategy: this.overlay.position().global()
|
||||
.centerHorizontally()
|
||||
.centerVertically(),
|
||||
scrollStrategy: this.overlay.scrollStrategies.block()
|
||||
});
|
||||
|
||||
this.overlayRef = overlayRef;
|
||||
|
||||
const componentRef = overlayRef.attach(new ComponentPortal(ProfileAvatarEditorComponent));
|
||||
|
||||
componentRef.setInput('source', source);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: ProcessedProfileAvatar | null): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
cancelSub.unsubscribe();
|
||||
confirmSub.unsubscribe();
|
||||
detachSub.unsubscribe();
|
||||
|
||||
if (this.overlayRef === overlayRef) {
|
||||
this.overlayRef = null;
|
||||
}
|
||||
|
||||
overlayRef.dispose();
|
||||
resolve(result);
|
||||
};
|
||||
const cancelSub = componentRef.instance.cancelled.subscribe(() => finish(null));
|
||||
const confirmSub = componentRef.instance.confirmed.subscribe((avatar) => finish(avatar));
|
||||
const detachSub = overlayRef.detachments().subscribe(() => finish(null));
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.overlayRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayRef = this.overlayRef;
|
||||
|
||||
this.overlayRef = null;
|
||||
overlayRef.dispose();
|
||||
}
|
||||
|
||||
private syncThemeVars(): void {
|
||||
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
|
||||
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
|
||||
|
||||
if (!appRoot || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const prop of Array.from(appRoot.style)) {
|
||||
if (prop.startsWith('--')) {
|
||||
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from './domain/profile-avatar.models';
|
||||
export { ProfileAvatarFacade } from './application/services/profile-avatar.facade';
|
||||
export { ProfileAvatarEditorComponent } from './feature/profile-avatar-editor/profile-avatar-editor.component';
|
||||
export {
|
||||
PROFILE_AVATAR_EDITOR_OVERLAY_CLASS,
|
||||
ProfileAvatarEditorService
|
||||
} from './feature/profile-avatar-editor/profile-avatar-editor.service';
|
||||
@@ -1,49 +0,0 @@
|
||||
/* eslint-disable @stylistic/js/array-element-newline */
|
||||
import { isAnimatedGif, isAnimatedWebp } from './profile-avatar-image.service';
|
||||
|
||||
describe('profile-avatar image animation detection', () => {
|
||||
it('detects animated gifs with multiple frames', () => {
|
||||
const animatedGif = new Uint8Array([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||
0x3B
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedGif(animatedGif)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark single-frame gifs as animated', () => {
|
||||
const staticGif = new Uint8Array([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00,
|
||||
0x3B
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedGif(staticGif)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects animated webp files from the VP8X animation flag', () => {
|
||||
const animatedWebp = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50,
|
||||
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
|
||||
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedWebp(animatedWebp)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark static webp files as animated', () => {
|
||||
const staticWebp = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50,
|
||||
0x56, 0x50, 0x38, 0x58, 0x0A, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
]).buffer;
|
||||
|
||||
expect(isAnimatedWebp(staticWebp)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
PROFILE_AVATAR_ALLOWED_MIME_TYPES,
|
||||
PROFILE_AVATAR_OUTPUT_SIZE,
|
||||
ProfileAvatarTransform,
|
||||
EditableProfileAvatarSource,
|
||||
ProcessedProfileAvatar,
|
||||
clampProfileAvatarTransform,
|
||||
PROFILE_AVATAR_EDITOR_FRAME_SIZE,
|
||||
resolveProfileAvatarBaseScale
|
||||
} from '../../domain/profile-avatar.models';
|
||||
|
||||
const PROFILE_AVATAR_OUTPUT_MIME = 'image/webp';
|
||||
const PROFILE_AVATAR_OUTPUT_QUALITY = 0.92;
|
||||
|
||||
export function isAnimatedGif(buffer: ArrayBuffer): boolean {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (bytes.length < 13 || readAscii(bytes, 0, 6) !== 'GIF87a' && readAscii(bytes, 0, 6) !== 'GIF89a') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let offset = 13;
|
||||
|
||||
if ((bytes[10] & 0x80) !== 0) {
|
||||
offset += 3 * (2 ** ((bytes[10] & 0x07) + 1));
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const blockType = bytes[offset];
|
||||
|
||||
if (blockType === 0x3B) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blockType === 0x21) {
|
||||
offset += 2;
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const blockSize = bytes[offset++];
|
||||
|
||||
if (blockSize === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += blockSize;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockType !== 0x2C || offset + 10 > bytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
frameCount++;
|
||||
|
||||
if (frameCount > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const packedFields = bytes[offset + 9];
|
||||
|
||||
offset += 10;
|
||||
|
||||
if ((packedFields & 0x80) !== 0) {
|
||||
offset += 3 * (2 ** ((packedFields & 0x07) + 1));
|
||||
}
|
||||
|
||||
offset += 1;
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const blockSize = bytes[offset++];
|
||||
|
||||
if (blockSize === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += blockSize;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAnimatedWebp(buffer: ArrayBuffer): boolean {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (bytes.length < 16 || readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const chunkType = readAscii(bytes, offset, 4);
|
||||
const chunkSize = readUint32LittleEndian(bytes, offset + 4);
|
||||
|
||||
if (chunkType === 'ANIM' || chunkType === 'ANMF') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (chunkType === 'VP8X' && offset + 9 <= bytes.length) {
|
||||
const featureFlags = bytes[offset + 8];
|
||||
|
||||
if ((featureFlags & 0x02) !== 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
offset += 8 + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileAvatarImageService {
|
||||
validateFile(file: File): string | null {
|
||||
const mimeType = file.type.toLowerCase();
|
||||
const normalizedName = file.name.toLowerCase();
|
||||
const isAllowedMime = PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES[number]);
|
||||
const isAllowedExtension = normalizedName.endsWith('.webp')
|
||||
|| normalizedName.endsWith('.gif')
|
||||
|| normalizedName.endsWith('.jpg')
|
||||
|| normalizedName.endsWith('.jpeg');
|
||||
|
||||
if (!isAllowedExtension || (mimeType && !isAllowedMime)) {
|
||||
return 'Invalid file type. Use WebP, GIF, JPG, or JPEG.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async prepareEditableSource(file: File): Promise<EditableProfileAvatarSource> {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const mime = this.resolveSourceMime(file);
|
||||
|
||||
try {
|
||||
const [image, preservesAnimation] = await Promise.all([this.loadImage(objectUrl), this.detectAnimatedSource(file, mime)]);
|
||||
|
||||
return {
|
||||
file,
|
||||
objectUrl,
|
||||
mime,
|
||||
name: file.name,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
preservesAnimation
|
||||
};
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void {
|
||||
if (!source?.objectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(source.objectUrl);
|
||||
}
|
||||
|
||||
async processEditableSource(
|
||||
source: EditableProfileAvatarSource,
|
||||
transform: ProfileAvatarTransform
|
||||
): Promise<ProcessedProfileAvatar> {
|
||||
if (source.preservesAnimation) {
|
||||
return this.processAnimatedSource(source);
|
||||
}
|
||||
|
||||
const image = await this.loadImage(source.objectUrl);
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = PROFILE_AVATAR_OUTPUT_SIZE;
|
||||
canvas.height = PROFILE_AVATAR_OUTPUT_SIZE;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Canvas not supported');
|
||||
}
|
||||
|
||||
const clampedTransform = clampProfileAvatarTransform(source, transform);
|
||||
const previewScale = resolveProfileAvatarBaseScale(source, PROFILE_AVATAR_EDITOR_FRAME_SIZE) * clampedTransform.zoom;
|
||||
const renderRatio = PROFILE_AVATAR_OUTPUT_SIZE / PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||
const drawWidth = image.naturalWidth * previewScale * renderRatio;
|
||||
const drawHeight = image.naturalHeight * previewScale * renderRatio;
|
||||
const drawX = (PROFILE_AVATAR_OUTPUT_SIZE - drawWidth) / 2 + clampedTransform.offsetX * renderRatio;
|
||||
const drawY = (PROFILE_AVATAR_OUTPUT_SIZE - drawHeight) / 2 + clampedTransform.offsetY * renderRatio;
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
const renderedBlob = await this.canvasToBlob(canvas, PROFILE_AVATAR_OUTPUT_MIME, PROFILE_AVATAR_OUTPUT_QUALITY);
|
||||
const compressedBlob = renderedBlob;
|
||||
const updatedAt = Date.now();
|
||||
const dataUrl = await this.readBlobAsDataUrl(compressedBlob);
|
||||
const hash = await this.computeHash(compressedBlob);
|
||||
|
||||
return {
|
||||
blob: compressedBlob,
|
||||
base64: dataUrl.split(',', 2)[1] ?? '',
|
||||
avatarUrl: dataUrl,
|
||||
avatarHash: hash,
|
||||
avatarMime: compressedBlob.type || PROFILE_AVATAR_OUTPUT_MIME,
|
||||
avatarUpdatedAt: updatedAt,
|
||||
width: PROFILE_AVATAR_OUTPUT_SIZE,
|
||||
height: PROFILE_AVATAR_OUTPUT_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
private async processAnimatedSource(source: EditableProfileAvatarSource): Promise<ProcessedProfileAvatar> {
|
||||
const updatedAt = Date.now();
|
||||
const dataUrl = await this.readBlobAsDataUrl(source.file);
|
||||
const hash = await this.computeHash(source.file);
|
||||
|
||||
return {
|
||||
blob: source.file,
|
||||
base64: dataUrl.split(',', 2)[1] ?? '',
|
||||
avatarUrl: dataUrl,
|
||||
avatarHash: hash,
|
||||
avatarMime: source.mime || source.file.type || PROFILE_AVATAR_OUTPUT_MIME,
|
||||
avatarUpdatedAt: updatedAt,
|
||||
width: source.width,
|
||||
height: source.height
|
||||
};
|
||||
}
|
||||
|
||||
private async detectAnimatedSource(file: File, mime: string): Promise<boolean> {
|
||||
if (mime !== 'image/gif' && mime !== 'image/webp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
return mime === 'image/gif'
|
||||
? isAnimatedGif(buffer)
|
||||
: isAnimatedWebp(buffer);
|
||||
}
|
||||
|
||||
private resolveSourceMime(file: File): string {
|
||||
const mimeType = file.type.toLowerCase();
|
||||
|
||||
if (mimeType === 'image/jpg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
if (mimeType) {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
const normalizedName = file.name.toLowerCase();
|
||||
|
||||
if (normalizedName.endsWith('.gif')) {
|
||||
return 'image/gif';
|
||||
}
|
||||
|
||||
if (normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg')) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
if (normalizedName.endsWith('.webp')) {
|
||||
return 'image/webp';
|
||||
}
|
||||
|
||||
return PROFILE_AVATAR_OUTPUT_MIME;
|
||||
}
|
||||
|
||||
private async computeHash(blob: Blob): Promise<string> {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to render avatar image'));
|
||||
}, type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
private readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to encode avatar image'));
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Failed to load avatar image'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readAscii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
return String.fromCharCode(...bytes.slice(offset, offset + length));
|
||||
}
|
||||
|
||||
function readUint32LittleEndian(bytes: Uint8Array, offset: number): number {
|
||||
return bytes[offset]
|
||||
| (bytes[offset + 1] << 8)
|
||||
| (bytes[offset + 2] << 16)
|
||||
| (bytes[offset + 3] << 24);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { resolveProfileAvatarStorageFileName, type ProcessedProfileAvatar } from '../../domain/profile-avatar.models';
|
||||
|
||||
const LEGACY_PROFILE_FILE_NAMES = [
|
||||
'profile.webp',
|
||||
'profile.gif',
|
||||
'profile.jpg',
|
||||
'profile.jpeg',
|
||||
'profile.png'
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileAvatarStorageService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
async persistProcessedAvatar(
|
||||
user: Pick<User, 'id' | 'username' | 'displayName'>,
|
||||
avatar: Pick<ProcessedProfileAvatar, 'base64' | 'avatarMime'>
|
||||
): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appDataPath = await electronApi.getAppDataPath();
|
||||
const usernameSegment = this.sanitizePathSegment(user.username || user.displayName || user.id || 'user');
|
||||
const directoryPath = `${appDataPath}/user/${usernameSegment}/profile`;
|
||||
const targetFileName = resolveProfileAvatarStorageFileName(avatar.avatarMime);
|
||||
|
||||
await electronApi.ensureDir(directoryPath);
|
||||
|
||||
for (const fileName of LEGACY_PROFILE_FILE_NAMES) {
|
||||
const filePath = `${directoryPath}/${fileName}`;
|
||||
|
||||
if (fileName !== targetFileName && await electronApi.fileExists(filePath)) {
|
||||
await electronApi.deleteFile(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
await electronApi.writeFile(`${directoryPath}/${targetFileName}`, avatar.base64);
|
||||
}
|
||||
|
||||
private sanitizePathSegment(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return normalized || 'user';
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,6 @@ describe('room-signal-source helpers', () => {
|
||||
expect(areRoomSignalSourcesEqual(
|
||||
{ sourceUrl: 'https://signal.toju.app/' },
|
||||
{ signalingUrl: 'wss://signal.toju.app' }
|
||||
)).toBe(true);
|
||||
)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ThemeGridEditorItem,
|
||||
ThemeGridRect,
|
||||
ThemeLayoutContainerDefinition
|
||||
} from '../../../domain/models/theme.model';
|
||||
} from '../../domain/models/theme.model';
|
||||
|
||||
type DragMode = 'move' | 'resize';
|
||||
|
||||
@@ -8,26 +8,26 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeRegistryEntry
|
||||
} from '../../../domain/models/theme.model';
|
||||
} from '../../domain/models/theme.model';
|
||||
import {
|
||||
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
||||
THEME_ELEMENT_STYLE_FIELDS,
|
||||
createAnimationStarterDefinition,
|
||||
getSuggestedFieldDefault
|
||||
} from '../../../domain/logic/theme-schema.logic';
|
||||
import { ElementPickerService } from '../../../application/services/element-picker.service';
|
||||
import { LayoutSyncService } from '../../../application/services/layout-sync.service';
|
||||
import { ThemeLibraryService } from '../../../application/services/theme-library.service';
|
||||
import { ThemeRegistryService } from '../../../application/services/theme-registry.service';
|
||||
import { ThemeService } from '../../../application/services/theme.service';
|
||||
import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.constants';
|
||||
import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component';
|
||||
import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component';
|
||||
} from '../../domain/logic/theme-schema.logic';
|
||||
import { ElementPickerService } from '../../application/services/element-picker.service';
|
||||
import { LayoutSyncService } from '../../application/services/layout-sync.service';
|
||||
import { ThemeLibraryService } from '../../application/services/theme-library.service';
|
||||
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
|
||||
import { ThemeService } from '../../application/services/theme.service';
|
||||
import { THEME_LLM_GUIDE } from '../../domain/constants/theme-llm-guide.constants';
|
||||
import { ThemeGridEditorComponent } from './theme-grid-editor.component';
|
||||
import { ThemeJsonCodeEditorComponent } from './theme-json-code-editor.component';
|
||||
|
||||
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ElementPickerService } from '../../application/services/element-picker.service';
|
||||
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
|
||||
import { ElementPickerService } from '../application/services/element-picker.service';
|
||||
import { ThemeRegistryService } from '../application/services/theme-registry.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-picker-overlay',
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user