fix: Fix multiple bugs with new authentication flow

This commit is contained in:
2026-06-07 15:04:21 +02:00
parent 9fc26b1ccf
commit 83456c018c
137 changed files with 4710 additions and 281 deletions

View File

@@ -25,6 +25,20 @@ Durable rules for AI agents working on this project. Read this file at session s
## Lessons ## Lessons
### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
- **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races.
- **Rule:** cache the last validated `metoyou_currentUserId` and only re-run backend initialization when that scope changes or an in-flight initialize completes with a different scope.
- **Why:** per-operation revalidation fans out across ban lookups, room loads, and message reads, causing channel/chat UI to stay blank until repeated server clicks eventually win the race.
- **Example:** `ensureReady()` returns immediately when `isReady()` and `validatedUserScope` still match `getStoredCurrentUserId()`.
### Restore local user scope before protected writes [authentication] [persistence]
- **Trigger:** a logged-in in-memory user can create rooms or messages after `metoyou_currentUserId` was cleared by a late session-expired path.
- **Rule:** before protected local persistence or server-directory actions, restore `metoyou_currentUserId` from the current user and avoid treating a live current user as unauthenticated.
- **Why:** otherwise rooms/messages fall into the anonymous IndexedDB scope, and route checks redirect to login even though NgRx still has the authenticated user.
- **Example:** `MessagesEffects.sendMessage$`, `RoomsEffects.createRoom$`, and server-directory create/join components call `setStoredCurrentUserId(currentUser.id)` before writing or joining.
### Persisted local user state still requires a session token [authentication] [signaling] ### Persisted local user state still requires a session token [authentication] [signaling]
- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped. - **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.

View File

@@ -0,0 +1,16 @@
# ADR-0003: Multi-Client Sessions with Connection-Scoped Routing
## Status
Accepted
## Context
Users expect to stay logged in on multiple devices simultaneously (Discord-style). The signaling server already issued multiple session tokens per user, but WebSocket broadcasts deduplicated by `oderId`, which prevented a user's second device from receiving chat, typing, or voice-state updates from their first device. Voice had no per-device identity, so two clients could both attempt to transmit audio.
## Decision
Introduce a stable per-install `clientInstanceId` on the product client. Route server broadcasts by **connection id** (exclude only the sender socket) while keeping presence `user_joined` / `user_left` identity-scoped. Track `voiceActive` per connection; relay RTC to the voice-active socket. Enforce single voice owner per user via `VoiceState.clientInstanceId` and `voice_client_takeover` handoff between connections.
## Consequences
- **Positive:** Chat and presence sync across a user's devices; voice behaves like Discord (one transmitting client, passive viewers, explicit takeover).
- **Positive:** Stale-tab hygiene uses `(oderId, connectionScope, clientInstanceId)` eviction without kicking other devices.
- **Negative:** `findUserByOderId` semantics change — RTC now prefers voice-active connections; callers must not assume one socket per user.
- **Negative:** Clients must include `clientInstanceId` on identify and voice payloads; older builds without it still work but cannot participate in multi-device voice exclusivity reliably.

View File

@@ -25,7 +25,7 @@ Session-token authentication for the signaling server and product client.
``` ```
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`). - Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
- Default TTL: 24 hours (`SESSION_TOKEN_TTL_MS` env override supported). - Default TTL: 10 years (`SESSION_TOKEN_TTL_MS` env override supported on the signaling server).
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login. - Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
## Protected REST routes ## Protected REST routes
@@ -46,13 +46,22 @@ Require `Authorization: Bearer`:
"token": "<session-token>", "token": "<session-token>",
"oderId": "<user-id>", "oderId": "<user-id>",
"displayName": "Alice", "displayName": "Alice",
"connectionScope": "ws://host:3001" "connectionScope": "ws://host:3001",
"clientInstanceId": "<per-install-uuid>"
} }
``` ```
- `oderId` must match the token's user id when provided. - `oderId` must match the token's user id when provided.
- `clientInstanceId` is a stable per-install UUID generated by the product client (`metoyou.clientInstanceId` in `localStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership.
- Server responds with `auth_error` or `auth_required` when authentication fails. - Server responds with `auth_error` or `auth_required` when authentication fails.
## Multi-device sessions
- Each login/register issues a **new** session token; prior tokens remain valid until they expire or the client calls `POST /api/users/logout` with that token.
- The same user may keep multiple WebSocket connections open (different devices or browser profiles). Server broadcasts (chat, typing, voice state, status) exclude only the **sending connection**, so other connections for that identity still receive updates.
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
## Client storage ## Client storage
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server. The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.

View File

@@ -48,7 +48,8 @@ export const test = base.extend<MultiClientFixture>({
const context = await browser.newContext({ const context = await browser.newContext({
permissions: ['microphone', 'camera'], permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200' baseURL: 'http://localhost:4200',
viewport: { width: 1440, height: 900 }
}); });
await installTestServerEndpoint(context, testServer.port); await installTestServerEndpoint(context, testServer.port);

View File

@@ -0,0 +1,205 @@
import { expect, type Page } from '@playwright/test';
import { type Client } from '../fixtures/multi-client';
import { LoginPage } from '../pages/login.page';
import { RegisterPage } from '../pages/register.page';
import { ServerSearchPage } from '../pages/server-search.page';
import { ChatRoomPage } from '../pages/chat-room.page';
import { ChatMessagesPage } from '../pages/chat-messages.page';
export const MULTI_DEVICE_PASSWORD = 'TestPass123!';
export const MULTI_DEVICE_VOICE_CHANNEL = 'General';
export interface MultiDeviceCredentials {
username: string;
displayName: string;
password: string;
}
export interface MultiDeviceScenario {
clientA: Client;
clientB: Client;
credentials: MultiDeviceCredentials;
serverName: string;
messagesA: ChatMessagesPage;
messagesB: ChatMessagesPage;
roomA: ChatRoomPage;
roomB: ChatRoomPage;
}
export function uniqueMultiDeviceName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
}
export async function createMultiDeviceScenario(
createClient: () => Promise<Client>,
options: { suffix?: string; serverDescription?: string } = {}
): Promise<MultiDeviceScenario> {
const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device');
const credentials: MultiDeviceCredentials = {
username: `multi_${suffix}`,
displayName: 'Multi Device User',
password: MULTI_DEVICE_PASSWORD
};
const serverName = `Multi Device Server ${suffix}`;
const clientA = await createClient();
const clientB = await createClient();
await warmClientPage(clientA.page);
await warmClientPage(clientB.page);
const registerPage = new RegisterPage(clientA.page);
await registerPage.goto();
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const searchA = new ServerSearchPage(clientA.page);
await searchA.createServer(serverName, {
description: options.serverDescription ?? 'Multi-device session coverage'
});
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(clientA.page, serverName);
const roomA = new ChatRoomPage(clientA.page);
await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
await waitForCurrentRoomName(clientB.page, serverName);
const messagesA = new ChatMessagesPage(clientA.page);
const messagesB = new ChatMessagesPage(clientB.page);
const roomB = new ChatRoomPage(clientB.page);
await messagesA.waitForReady();
await messagesB.waitForReady();
return {
clientA,
clientB,
credentials,
serverName,
messagesA,
messagesB,
roomA,
roomB
};
}
export async function loginSecondDeviceIntoServer(
page: Page,
credentials: MultiDeviceCredentials,
serverName: string
): Promise<void> {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(credentials.username, credentials.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(page);
await search.joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
}
export async function expectCrossDeviceMessage(
sender: ChatMessagesPage,
receiver: ChatMessagesPage,
message: string,
timeout = 60_000
): Promise<void> {
await sender.sendMessage(message);
await expect.poll(async () => {
return await receiver.getMessageItemByText(message).isVisible().catch(() => false);
}, { timeout }).toBe(true);
}
async function warmClientPage(page: Page): Promise<void> {
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => undefined);
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
export async function readClientInstanceId(page: Page): Promise<string | null> {
return page.evaluate(() => localStorage.getItem('metoyou.clientInstanceId'));
}
export async function logoutFromMenu(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
}
export function channelsSidePanel(page: Page) {
return page.locator('app-rooms-side-panel').first();
}
export function membersSidePanel(page: Page) {
return page.locator('app-rooms-side-panel').last();
}
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
return page
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
.getByText('Join', { exact: true });
}
export async function expectPassiveVoiceOnDevice(
page: Page,
options: { timeout?: number; displayName?: string; channelName?: string } = {}
): Promise<void> {
const timeout = options.timeout ?? 45_000;
const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL;
const displayName = options.displayName;
await expect.poll(async () => {
const membersLabel = await membersSidePanel(page)
.getByText('In voice on another device', { exact: false })
.isVisible()
.catch(() => false);
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible().catch(() => false);
const grayedVoiceUser = displayName
? await channelsSidePanel(page).locator('.opacity-50').filter({ hasText: displayName }).first().isVisible().catch(() => false)
: false;
return membersLabel || joinBadge || grayedVoiceUser;
}, { timeout }).toBe(true);
}
export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise<void> {
await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout });
}

View File

@@ -34,9 +34,22 @@ export class ChatMessagesPage {
} }
async sendMessage(content: string): Promise<void> { async sendMessage(content: string): Promise<void> {
await this.waitForReady(); let lastError: unknown;
await this.composerInput.fill(content);
await this.sendButton.click(); for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await this.waitForReady();
await this.composerInput.fill(content);
await expect(this.composerInput).toHaveValue(content, { timeout: 5_000 });
await expect(this.sendButton).toBeEnabled({ timeout: 5_000 });
await this.sendButton.click();
return;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error('Failed to send chat message');
} }
async typeDraft(content: string): Promise<void> { async typeDraft(content: string): Promise<void> {
@@ -44,6 +57,13 @@ export class ChatMessagesPage {
await this.composerInput.fill(content); await this.composerInput.fill(content);
} }
/** Types into the composer in a way that emits input/typing events (not just fill). */
async typeDraftWithTypingEvents(content: string): Promise<void> {
await this.waitForReady();
await this.composerInput.click();
await this.composerInput.pressSequentially(content, { delay: 40 });
}
async clearDraft(): Promise<void> { async clearDraft(): Promise<void> {
await this.waitForReady(); await this.waitForReady();
await this.composerInput.fill(''); await this.composerInput.fill('');

View File

@@ -10,15 +10,14 @@ export class LoginPage {
readonly registerLink: Locator; readonly registerLink: Locator;
constructor(private page: Page) { constructor(private page: Page) {
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]') this.form = page.locator('form').filter({ has: page.locator('#login-username') });
.first();
this.usernameInput = page.locator('#login-username'); this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password'); this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server'); this.serverSelect = page.locator('#login-server');
this.submitButton = this.form.getByRole('button', { name: 'Login' }); this.submitButton = this.form.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive'); this.errorText = page.locator('.text-destructive');
this.registerLink = this.form.getByRole('button', { name: 'Register' }); this.registerLink = page.getByRole('button', { name: 'Register' });
} }
async goto() { async goto() {

27
e2e/run-playwright.mjs Normal file
View File

@@ -0,0 +1,27 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const e2eDirectory = fileURLToPath(new URL('.', import.meta.url));
const env = { ...process.env };
const browsersPath = env.PLAYWRIGHT_BROWSERS_PATH;
if (browsersPath?.includes('/cursor-sandbox-cache/')) {
delete env.PLAYWRIGHT_BROWSERS_PATH;
}
const [command = 'test', ...args] = process.argv.slice(2);
const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(executable, ['playwright', command, ...args], {
cwd: e2eDirectory,
env,
stdio: 'inherit'
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

View File

@@ -0,0 +1,153 @@
import { test, expect } from '../../fixtures/multi-client';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
interface TestUser {
username: string;
displayName: string;
password: string;
}
test.describe('Login returnUrl handling', () => {
test.describe.configure({ timeout: 120_000 });
test('unwraps nested login returnUrl chains after successful login', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('nested-return');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Return Url User',
password: 'TestPass123!'
};
await test.step('Create an account', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
await test.step('Log out and open a deeply nested login returnUrl', async () => {
await logout(page);
const nestedReturnUrl = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
await page.goto(`/login?returnUrl=${encodeURIComponent(nestedReturnUrl)}`, {
waitUntil: 'domcontentloaded'
});
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
});
await test.step('Login lands on the original destination instead of looping on /login', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
});
test('redirects unauthenticated /servers visits to login and returns there after login', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('servers-return');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Servers Return User',
password: 'TestPass123!'
};
await test.step('Create an account and log out', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
await logout(page);
});
await test.step('Visiting /servers sends the user to a single-level login returnUrl', async () => {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
await test.step('Logging in returns to /servers', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
});
});
test('lets a returning user log back in after an expired session redirect', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('expired-session');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Expired Session User',
password: 'TestPass123!'
};
await test.step('Create an account', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
await test.step('Simulate an expired session while keeping the persisted user id', async () => {
await page.evaluate(() => {
const storageKey = 'metoyou.authTokens';
const raw = localStorage.getItem(storageKey);
if (!raw) {
return;
}
const parsed = JSON.parse(raw) as Record<string, { token: string; expiresAt: number }>;
const expiredStore = Object.fromEntries(
Object.entries(parsed).map(([url, entry]) => [url, { ...entry, expiresAt: 0 }])
);
localStorage.setItem(storageKey, JSON.stringify(expiredStore));
});
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
await test.step('The user can authenticate again and reach /servers', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
});
});
});
async function logout(page: import('@playwright/test').Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,93 @@
import {
test,
expect
} from '../../fixtures/multi-client';
import {
MULTI_DEVICE_VOICE_CHANNEL,
channelsSidePanel,
createMultiDeviceScenario,
expectCrossDeviceMessage,
expectActiveVoiceOnDevice,
expectPassiveVoiceOnDevice,
logoutFromMenu,
membersSidePanel,
passiveVoiceChannelJoinBadge,
readClientInstanceId,
uniqueMultiDeviceName
} from '../../helpers/multi-device-session';
test.describe('Multi-device session', () => {
test.describe.configure({ timeout: 300_000, retries: 1 });
test('covers identity, chat sync, typing exclusion, and voice exclusivity', async ({ createClient }) => {
const scenario = await createMultiDeviceScenario(createClient);
const messageAtoB = `Cross-device A to B ${uniqueMultiDeviceName('msg')}`;
const messageBtoA = `Cross-device B to A ${uniqueMultiDeviceName('msg')}`;
const typingDraft = `Typing draft ${uniqueMultiDeviceName('draft')}`;
await test.step('assigns distinct clientInstanceId per browser context', async () => {
const instanceA = await readClientInstanceId(scenario.clientA.page);
const instanceB = await readClientInstanceId(scenario.clientB.page);
expect(instanceA).toBeTruthy();
expect(instanceB).toBeTruthy();
expect(instanceA).not.toEqual(instanceB);
});
await test.step('syncs chat from device A to device B', async () => {
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
});
await test.step('syncs chat from device B to device A', async () => {
await expectCrossDeviceMessage(scenario.messagesB, scenario.messagesA, messageBtoA);
});
await test.step('does not show own typing indicator on the other device for the same user', async () => {
await scenario.messagesA.typeDraftWithTypingEvents(typingDraft);
await expect(
scenario.clientB.page.getByText(`${scenario.credentials.displayName} is typing`, { exact: false })
).toHaveCount(0, { timeout: 5_000 });
});
await test.step('shows passive in-voice UI on the second device when the first joins voice', async () => {
await scenario.roomA.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expectActiveVoiceOnDevice(scenario.clientA.page);
await expectPassiveVoiceOnDevice(scenario.clientB.page, {
displayName: scenario.credentials.displayName
});
await expect(
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
).toBeVisible({ timeout: 20_000 });
await expect(
channelsSidePanel(scenario.clientB.page).locator('.opacity-50').filter({
hasText: scenario.credentials.displayName
}).first()
).toBeVisible({ timeout: 20_000 });
});
await test.step('shows Join takeover affordance on passive device voice channel', async () => {
await expect(passiveVoiceChannelJoinBadge(scenario.clientB.page)).toBeVisible({ timeout: 20_000 });
});
await test.step('transfers voice ownership when the passive device takes over', async () => {
await scenario.roomB.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expectActiveVoiceOnDevice(scenario.clientB.page);
await expectPassiveVoiceOnDevice(scenario.clientA.page, {
displayName: scenario.credentials.displayName
});
});
await test.step('keeps the second device logged in when the first device logs out', async () => {
const message = `Still logged in ${uniqueMultiDeviceName('logout')}`;
await logoutFromMenu(scenario.clientA.page);
await scenario.messagesB.sendMessage(message);
await expect(scenario.messagesB.getMessageItemByText(message)).toBeVisible({ timeout: 20_000 });
await expect(scenario.clientB.page).toHaveURL(/\/room\//, { timeout: 10_000 });
});
});
});

View File

@@ -48,14 +48,13 @@ test.describe('User session data isolation', () => {
await test.step('Alice registers and creates local chat history', async () => { await test.step('Alice registers and creates local chat history', async () => {
await registerUser(client.page, alice); await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage); await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
}); });
await test.step('Alice sees the same saved room and message after a full restart', async () => { await test.step('Alice sees the same saved room and message after a full restart', async () => {
await restartPersistentClient(client, testServer.port); await restartPersistentClient(client, testServer.port);
await openApp(client.page); await openApp(client.page);
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 }); await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
}); });
} finally { } finally {
await closePersistentClient(client); await closePersistentClient(client);
@@ -88,11 +87,11 @@ test.describe('User session data isolation', () => {
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => { await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
await registerUser(client.page, alice); await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage); await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
await restartPersistentClient(client, testServer.port); await restartPersistentClient(client, testServer.port);
await openApp(client.page); await openApp(client.page);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage); await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
}); });
await test.step('Bob starts from a blank slate in the same browser profile', async () => { await test.step('Bob starts from a blank slate in the same browser profile', async () => {
@@ -102,11 +101,11 @@ test.describe('User session data isolation', () => {
}); });
await test.step('Bob gets only his own saved room and history after a restart', async () => { await test.step('Bob gets only his own saved room and history after a restart', async () => {
await createServerAndSendMessage(client.page, bobServerName, bobMessage); await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
await restartPersistentClient(client, testServer.port); await restartPersistentClient(client, testServer.port);
await openApp(client.page); await openApp(client.page);
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage); await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
await expectSavedRoomHidden(client.page, aliceServerName); await expectSavedRoomHidden(client.page, aliceServerName);
}); });
@@ -117,7 +116,7 @@ test.describe('User session data isolation', () => {
await expectSavedRoomVisible(client.page, aliceServerName); await expectSavedRoomVisible(client.page, aliceServerName);
await expectSavedRoomHidden(client.page, bobServerName); await expectSavedRoomHidden(client.page, bobServerName);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage); await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
}); });
} finally { } finally {
await closePersistentClient(client); await closePersistentClient(client);
@@ -194,32 +193,58 @@ async function logoutUser(page: Page): Promise<void> {
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 }); await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
} }
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> { async function createServerAndSendMessage(page: Page, user: TestUser, serverName: string, messageText: string): Promise<void> {
const searchPage = new ServerSearchPage(page); const searchPage = new ServerSearchPage(page);
const messagesPage = new ChatMessagesPage(page); const messagesPage = new ChatMessagesPage(page);
await searchPage.createServer(serverName, { await loginIfNeeded(page, user);
description: `User session isolation coverage for ${serverName}` await ensureCurrentUserScope(page, user);
}); await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
if (await waitForLoginForm(page, 5_000)) {
await loginUser(page, user);
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
}
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
await searchPage.serverNameInput.fill(serverName);
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
await searchPage.createSubmitButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesPage.sendMessage(messageText); await messagesPage.sendMessage(messageText);
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
await expectMessagePersistedInIndexedDb(page, messageText);
} }
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> { async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
const railRoomButton = getRailSavedRoomButton(page, roomName); if (await waitForVisibleText(page, messageText, 5_000)) {
const messagesPage = new ChatMessagesPage(page); return;
}
await expect(railRoomButton).toBeVisible({ timeout: 20_000 }); if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
await page.goto('/servers', { waitUntil: 'domcontentloaded' }); await loginUser(page, user);
const searchRoomButton = getSearchSavedRoomButton(page, roomName); }
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 }); await expectMessagePersistedInIndexedDb(page, messageText);
await searchRoomButton.click();
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
if (persistedRoomId) {
await openPersistedRoomById(page, user, persistedRoomId);
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
return;
}
if (await openSavedRoomFromRail(page, roomName)) {
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
return;
}
await joinServerFromSearchAfterLogin(page, user, roomName);
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
} }
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> { async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
@@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
} }
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> { async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); if (await page.getByText(roomName, { exact: false }).first()
.isVisible()
.catch(() => false)) {
return;
}
await page.goto('/servers', { waitUntil: 'domcontentloaded' }); await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
} }
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> { async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
if (!page.url().includes('/servers')) { if (!page.url().includes('/servers')) {
await page.goto('/servers', { waitUntil: 'domcontentloaded' }); await page.goto('/servers', { waitUntil: 'domcontentloaded' });
} }
@@ -247,14 +275,227 @@ async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0); await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
} }
function getRailSavedRoomButton(page: Page, roomName: string) {
return page.locator(`button[title="${roomName}"]`).first();
}
function getSearchSavedRoomButton(page: Page, roomName: string) { function getSearchSavedRoomButton(page: Page, roomName: string) {
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true }); return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
} }
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
try {
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
const expectedPrefix = expectedName.slice(0, 24);
const button = buttons.find((candidate) => {
const title = (candidate as HTMLButtonElement).title;
return title === expectedName || title.startsWith(expectedPrefix);
}) as HTMLButtonElement | undefined;
button?.click();
return !!button;
}, roomName);
if (!clicked) {
return await openSavedRoomFromDashboard(page, roomName);
}
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return await openSavedRoomFromDashboard(page, roomName);
}
}
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
try {
await expect(roomButton).toBeVisible({ timeout: 10_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return await joinVisibleServerFromDashboard(page, roomNamePattern);
}
}
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
has: page.getByRole('button', { name: 'Join' })
})
.last();
const joinButton = serverRow.getByRole('button', { name: 'Join' });
try {
await expect(joinButton).toBeVisible({ timeout: 10_000 });
await joinButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return false;
}
}
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
await loginIfNeeded(page, user);
await searchPage.goto();
if (!await waitForServerSearch(page, 5_000)) {
await loginUser(page, user);
await searchPage.goto();
}
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
await searchPage.searchInput.fill(roomName);
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
}
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
const loginPage = new LoginPage(page);
if (page.url().includes('/login')) {
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
await loginUser(page, user);
return;
}
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
await loginUser(page, user);
}
}
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
if (await hasCurrentUserScope(page)) {
return;
}
await loginUser(page, user);
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
}
async function hasCurrentUserScope(page: Page): Promise<boolean> {
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
}
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
for (let attempt = 1; attempt <= 3; attempt += 1) {
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
if (await waitForLoginForm(page, 5_000)) {
await loginUser(page, user);
continue;
}
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
if (!await waitForLoginForm(page, 2_000)) {
return;
}
await loginUser(page, user);
}
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
}
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
try {
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
try {
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
try {
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
await expect.poll(
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
{ timeout: 10_000 }
).toBe(true);
}
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
return page.evaluate(async (expectedContent) => {
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
? (await indexedDB.databases())
.map((database) => database.name)
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
: null;
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
? [preferredDatabaseName].concat(remainingDatabaseNames)
: remainingDatabaseNames;
for (const databaseName of orderedDatabaseNames) {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
try {
if (!database.objectStoreNames.contains('messages')) {
continue;
}
const transaction = database.transaction('messages', 'readonly');
const request = transaction.objectStore('messages').getAll();
const roomId = await new Promise<string | null>((resolve, reject) => {
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
.find((message) => message.content === expectedContent);
resolve(match?.roomId ?? null);
};
});
if (roomId) {
return roomId;
}
} finally {
database.close();
}
}
return null;
}, messageText);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> { async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown; let lastError: unknown;

View File

@@ -150,6 +150,8 @@ test.describe('Mixed signal-config voice', () => {
} }
}); });
let secondaryRoomId = '';
// ── Create rooms ──────────────────────────────────────────── // ── Create rooms ────────────────────────────────────────────
await test.step('Create voice room on primary and chat room on secondary', async () => { await test.step('Create voice room on primary and chat room on secondary', async () => {
// Use a "both" user (client 0) to create both rooms // Use a "both" user (client 0) to create both rooms
@@ -198,7 +200,6 @@ test.describe('Mixed signal-config voice', () => {
// Group D (secondary-only) needs invite to primary room. // Group D (secondary-only) needs invite to primary room.
let primaryRoomInviteUrl: string; let primaryRoomInviteUrl: string;
let secondaryRoomInviteUrl: string; let secondaryRoomInviteUrl: string;
let secondaryRoomId = '';
await test.step('Create invite links for cross-signal rooms', async () => { await test.step('Create invite links for cross-signal rooms', async () => {
// Navigate to voice room to get its ID // Navigate to voice room to get its ID

View File

@@ -0,0 +1,14 @@
import {
describe,
expect,
it
} from 'vitest';
import { getLocalApiTokenTtlMs } from './auth-store';
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
describe('auth-store', () => {
it('defaults local API tokens to a very long lifetime', () => {
expect(getLocalApiTokenTtlMs()).toBe(TEN_YEARS_MS);
});
});

View File

@@ -10,9 +10,13 @@ export interface IssuedToken {
expiresAt: number; expiresAt: number;
} }
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
const tokens = new Map<string, IssuedToken>(); const tokens = new Map<string, IssuedToken>();
export function getLocalApiTokenTtlMs(): number {
return DEFAULT_TOKEN_TTL_MS;
}
export function issueToken(params: { export function issueToken(params: {
userId: string; userId: string;
username: string; username: string;
@@ -24,7 +28,7 @@ export function issueToken(params: {
const issued: IssuedToken = { const issued: IssuedToken = {
token, token,
issuedAt, issuedAt,
expiresAt: issuedAt + TOKEN_TTL_MS, expiresAt: issuedAt + getLocalApiTokenTtlMs(),
userId: params.userId, userId: params.userId,
username: params.username, username: params.username,
displayName: params.displayName, displayName: params.displayName,

View File

@@ -22,6 +22,12 @@ import {
setupWindowControlHandlers setupWindowControlHandlers
} from '../ipc'; } from '../ipc';
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor'; import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
import {
attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered,
shutdownPerfDiagnostics,
startPerfDiagnostics
} from '../diagnostics';
function startLocalApiAfterWindowReady(): void { function startLocalApiAfterWindowReady(): void {
setImmediate(() => { setImmediate(() => {
@@ -32,6 +38,8 @@ function startLocalApiAfterWindowReady(): void {
} }
export function registerAppLifecycle(): void { export function registerAppLifecycle(): void {
ensurePerfDiagIpcRegistered();
app.whenReady().then(async () => { app.whenReady().then(async () => {
const dockIconPath = getDockIconPath(); const dockIconPath = getDockIconPath();
@@ -45,7 +53,15 @@ export function registerAppLifecycle(): void {
await migrateLegacyDesktopBranding(); await migrateLegacyDesktopBranding();
await synchronizeAutoStartSetting(); await synchronizeAutoStartSetting();
initializeDesktopUpdater(); initializeDesktopUpdater();
startPerfDiagnostics();
await createWindow(); await createWindow();
const mainWindow = getMainWindow();
if (mainWindow) {
attachRendererDiagnosticsHooks(mainWindow);
}
startLocalApiAfterWindowReady(); startLocalApiAfterWindowReady();
startIdleMonitor(); startIdleMonitor();
@@ -67,6 +83,7 @@ export function registerAppLifecycle(): void {
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit(); prepareWindowForAppQuit();
await shutdownPerfDiagnostics();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();

View File

@@ -0,0 +1,27 @@
import {
describe,
it,
expect
} from 'vitest';
import { isPerfDiagEnabled } from './diagnostics.flags';
describe('isPerfDiagEnabled', () => {
it('returns false when the flag is unset', () => {
expect(isPerfDiagEnabled({}, false)).toBe(false);
expect(isPerfDiagEnabled({}, true)).toBe(false);
});
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, false)).toBe(true);
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'true' }, false)).toBe(true);
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
});
it('returns false in packaged builds unless force is set', () => {
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
expect(isPerfDiagEnabled({
METOYOU_PERF_DIAG: '1',
METOYOU_PERF_DIAG_FORCE: '1'
}, true)).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
export const PERF_DIAG_ENV = 'METOYOU_PERF_DIAG';
export const PERF_DIAG_FORCE_ENV = 'METOYOU_PERF_DIAG_FORCE';
const TRUTHY = new Set([
'1',
'true',
'yes',
'on'
]);
function isTruthyFlag(value: string | undefined): boolean {
return TRUTHY.has(String(value ?? '').trim()
.toLowerCase());
}
export function isPerfDiagEnabled(
env: NodeJS.ProcessEnv,
isPackaged: boolean
): boolean {
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
return false;
}
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
return false;
}
return true;
}

View File

@@ -0,0 +1,214 @@
import {
app,
BrowserWindow,
ipcMain
} from 'electron';
import { collectAppMetricsSnapshot } from '../app-metrics';
import { sumWorkingSetKb } from './process-metrics.rules';
import { isPerfDiagEnabled } from './diagnostics.flags';
import type { PerfDiagEntry } from './diagnostics.models';
import { PerfDiagWriter } from './diagnostics.writer';
const PROCESS_POLL_INTERVAL_MS = 5_000;
let activeWriter: PerfDiagWriter | null = null;
let processPollTimer: NodeJS.Timeout | null = null;
let diagnosticsEnabled = false;
let ipcRegistered = false;
export function isPerfDiagActive(): boolean {
return diagnosticsEnabled;
}
export function ensurePerfDiagIpcRegistered(): void {
if (ipcRegistered) {
return;
}
ipcRegistered = true;
ipcMain.handle('perf-diag-is-enabled', () => diagnosticsEnabled);
ipcMain.handle('perf-diag-report', (_event, entry: PerfDiagEntry) => {
const writer = activeWriter;
if (!diagnosticsEnabled || !writer) {
return false;
}
try {
writer.append(normalizeRendererEntry(entry));
return true;
} catch {
return false;
}
});
}
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
return activeWriter;
}
export function startPerfDiagnostics(): PerfDiagWriter | null {
ensurePerfDiagIpcRegistered();
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
if (!diagnosticsEnabled) {
return null;
}
const sessionId = `${Date.now().toString(36)}-${process.pid}`;
const writer = new PerfDiagWriter({
userDataPath: app.getPath('userData'),
sessionId
});
activeWriter = writer;
registerProcessCrashHandlers(writer);
startProcessMetricsPolling(writer);
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'session',
payload: {
event: 'started',
sessionId,
filePath: writer.snapshotFilePath
}
});
return writer;
}
export function attachRendererDiagnosticsHooks(window: BrowserWindow): void {
const writer = activeWriter;
if (!writer) {
return;
}
window.webContents.on('render-process-gone', (_event, details) => {
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'crash',
payload: {
reason: details.reason,
exitCode: details.exitCode
}
});
void writer.flushSnapshot('render-process-gone');
});
window.webContents.on('unresponsive', () => {
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'unresponsive',
payload: {}
});
});
window.webContents.on('responsive', () => {
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'session',
payload: { event: 'renderer-responsive' }
});
});
}
export async function shutdownPerfDiagnostics(): Promise<void> {
if (!activeWriter) {
return;
}
await activeWriter.flushSnapshot('shutdown');
if (processPollTimer) {
clearInterval(processPollTimer);
processPollTimer = null;
}
activeWriter = null;
diagnosticsEnabled = false;
}
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
app.on('child-process-gone', (_event, details) => {
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'crash',
payload: {
type: details.type,
reason: details.reason,
exitCode: details.exitCode,
serviceName: details.serviceName ?? null,
name: details.name ?? null
}
});
});
process.on('uncaughtException', (error) => {
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'crash',
payload: {
scope: 'main-uncaughtException',
message: error.message
}
});
void writer.flushSnapshot('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'crash',
payload: {
scope: 'main-unhandledRejection',
reason: String(reason)
}
});
});
}
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
const sample = (): void => {
try {
const metrics = collectAppMetricsSnapshot();
const totalKb = sumWorkingSetKb(metrics.processes);
writer.append({
collectedAt: metrics.collectedAt,
source: 'main',
type: 'process',
payload: {
totalWorkingSetKb: totalKb,
processes: metrics.processes
}
});
} catch {
// Collector failures must never affect the app.
}
};
sample();
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
}
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
return {
collectedAt: Number(entry.collectedAt) || Date.now(),
source: 'renderer',
type: entry.type,
payload: entry.payload ?? {}
};
}

View File

@@ -0,0 +1,17 @@
export type PerfDiagSource = 'main' | 'renderer';
export type PerfDiagEntryType =
| 'session'
| 'process'
| 'store'
| 'components'
| 'heap'
| 'crash'
| 'unresponsive';
export interface PerfDiagEntry {
collectedAt: number;
source: PerfDiagSource;
type: PerfDiagEntryType;
payload: Record<string, unknown>;
}

View File

@@ -0,0 +1,53 @@
import {
describe,
it,
expect
} from 'vitest';
import {
formatPerfDiagLine,
pushRingBuffer,
resolveDiagnosticsFilePath
} from './diagnostics.rules';
describe('pushRingBuffer', () => {
it('appends items until capacity is reached', () => {
expect(pushRingBuffer([1, 2], 3, 4)).toEqual([
1,
2,
3
]);
});
it('drops the oldest items when capacity is exceeded', () => {
expect(pushRingBuffer([
1,
2,
3
], 4, 3)).toEqual([
2,
3,
4
]);
});
});
describe('formatPerfDiagLine', () => {
it('serializes one JSON object per line', () => {
const line = formatPerfDiagLine({
collectedAt: 1_700_000_000_000,
source: 'main',
type: 'process',
payload: { browserKb: 128 }
});
expect(line).toBe('{"collectedAt":1700000000000,"source":"main","type":"process","payload":{"browserKb":128}}');
expect(line.endsWith('\n')).toBe(false);
});
});
describe('resolveDiagnosticsFilePath', () => {
it('places session files under diagnostics/', () => {
expect(resolveDiagnosticsFilePath('/tmp/user-data', 'session-1'))
.toBe('/tmp/user-data/diagnostics/perf-session-1.jsonl');
});
});

View File

@@ -0,0 +1,24 @@
import * as path from 'path';
import type { PerfDiagEntry } from './diagnostics.models';
export function pushRingBuffer<T>(items: readonly T[], item: T, capacity: number): T[] {
const next = [...items, item];
if (next.length <= capacity) {
return next;
}
return next.slice(next.length - capacity);
}
export function formatPerfDiagLine(entry: PerfDiagEntry): string {
return JSON.stringify(entry);
}
export function resolveDiagnosticsFilePath(userDataPath: string, sessionId: string): string {
return path.join(userDataPath, 'diagnostics', `perf-${sessionId}.jsonl`);
}
export function resolveDiagnosticsDirectory(userDataPath: string): string {
return path.join(userDataPath, 'diagnostics');
}

View File

@@ -0,0 +1,108 @@
import * as fsp from 'fs/promises';
import * as path from 'path';
import type { PerfDiagEntry } from './diagnostics.models';
import {
formatPerfDiagLine,
pushRingBuffer,
resolveDiagnosticsFilePath
} from './diagnostics.rules';
const DEFAULT_RING_CAPACITY = 120;
const FLUSH_DEBOUNCE_MS = 250;
export interface PerfDiagWriterOptions {
userDataPath: string;
sessionId: string;
ringCapacity?: number;
}
export class PerfDiagWriter {
private readonly filePath: string;
private readonly ringCapacity: number;
private readonly pendingLines: string[] = [];
private ring: PerfDiagEntry[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private flushInFlight: Promise<void> | null = null;
private disabled = false;
constructor(options: PerfDiagWriterOptions) {
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
}
get snapshotFilePath(): string {
return this.filePath;
}
get bufferedEntries(): readonly PerfDiagEntry[] {
return this.ring;
}
append(entry: PerfDiagEntry): void {
if (this.disabled) {
return;
}
try {
this.ring = pushRingBuffer(this.ring, entry, this.ringCapacity);
this.pendingLines.push(`${formatPerfDiagLine(entry)}\n`);
this.scheduleFlush();
} catch {
this.disabled = true;
}
}
async flush(): Promise<void> {
if (this.disabled || this.pendingLines.length === 0) {
return;
}
if (this.flushInFlight) {
await this.flushInFlight;
return;
}
const lines = this.pendingLines.splice(0, this.pendingLines.length);
this.flushInFlight = this.writeLines(lines)
.catch(() => {
this.disabled = true;
})
.finally(() => {
this.flushInFlight = null;
});
await this.flushInFlight;
}
async flushSnapshot(label: string): Promise<void> {
this.append({
collectedAt: Date.now(),
source: 'main',
type: 'session',
payload: {
event: label,
filePath: this.filePath,
entries: this.ring
}
});
await this.flush();
}
private scheduleFlush(): void {
if (this.flushTimer) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, FLUSH_DEBOUNCE_MS);
}
private async writeLines(lines: string[]): Promise<void> {
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
await fsp.appendFile(this.filePath, lines.join(''), 'utf8');
}
}

View File

@@ -0,0 +1,11 @@
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
export {
attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered,
getActivePerfDiagWriter,
isPerfDiagActive,
shutdownPerfDiagnostics,
startPerfDiagnostics
} from './diagnostics.lifecycle';
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
export { PerfDiagWriter } from './diagnostics.writer';

View File

@@ -0,0 +1,19 @@
export interface ProcessWorkingSetSnapshot {
workingSetKb: number | null;
}
export function sumWorkingSetKb(processes: readonly ProcessWorkingSetSnapshot[]): number | null {
let total = 0;
let hasAny = false;
for (const process of processes) {
if (process.workingSetKb == null || process.workingSetKb < 0) {
continue;
}
total += process.workingSetKb;
hasAny = true;
}
return hasAny ? total : null;
}

View File

@@ -252,6 +252,13 @@ export interface ElectronAPI {
workingSetKb: number | null; workingSetKb: number | null;
}[]; }[];
}>; }>;
isPerfDiagEnabled: () => Promise<boolean>;
reportPerfDiagSample: (entry: {
collectedAt: number;
source: 'main' | 'renderer';
type: string;
payload: Record<string, unknown>;
}) => Promise<boolean>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>; exportUserData: () => Promise<ExportUserDataResult>;
@@ -388,6 +395,8 @@ const electronAPI: ElectronAPI = {
}; };
}, },
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'), getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'), openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'), exportUserData: () => ipcRenderer.invoke('export-user-data'),

View File

@@ -52,10 +52,12 @@
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe", "server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
"sort:props": "node tools/sort-template-properties.js", "sort:props": "node tools/sort-template-properties.js",
"i18n:sync": "node tools/sync-app-i18n-catalog.mjs", "i18n:sync": "node tools/sync-app-i18n-catalog.mjs",
"test:e2e": "cd e2e && npx playwright test", "test:e2e": "node e2e/run-playwright.mjs test",
"test:e2e:ui": "cd e2e && npx playwright test --ui", "test:e2e:ui": "node e2e/run-playwright.mjs test --ui",
"test:e2e:debug": "cd e2e && npx playwright test --debug", "test:e2e:debug": "node e2e/run-playwright.mjs test --debug",
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report", "test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
"perf:diag:view": "node tools/perf-diag-viewer.js",
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
"cap:sync": "cd toju-app && npx cap sync", "cap:sync": "cd toju-app && npx cap sync",
"cap:open:android": "node tools/cap-open-android.js", "cap:open:android": "node tools/cap-open-android.js",
"cap:open:ios": "cd toju-app && npx cap open ios", "cap:open:ios": "cd toju-app && npx cap open ios",

View File

@@ -21,7 +21,9 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" | | **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" | | **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) | | **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" | | **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. Multiple valid tokens may exist per user (multi-device login). | "API key", "JWT" |
| **Client instance id** | Opaque per-install string on WebSocket `identify` and `voice_state`; used to distinguish connections for the same `oderId` and to track which connection owns active voice. | "device id" |
| **Voice-active connection** | WebSocket connection for a user with `voiceActive=true` after a connected `voice_state`; preferred target for RTC relay. | "voice owner socket" |
## Relationships ## Relationships

View File

@@ -0,0 +1,39 @@
import {
afterEach,
describe,
expect,
it
} from 'vitest';
import { getSessionTokenTtlMs } from './session-auth.service';
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
describe('session-auth.service', () => {
const originalTtl = process.env.SESSION_TOKEN_TTL_MS;
afterEach(() => {
if (originalTtl === undefined) {
delete process.env.SESSION_TOKEN_TTL_MS;
} else {
process.env.SESSION_TOKEN_TTL_MS = originalTtl;
}
});
it('defaults session tokens to a very long lifetime', () => {
delete process.env.SESSION_TOKEN_TTL_MS;
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
});
it('honors SESSION_TOKEN_TTL_MS when configured', () => {
process.env.SESSION_TOKEN_TTL_MS = '3600000';
expect(getSessionTokenTtlMs()).toBe(3_600_000);
});
it('falls back to the default when SESSION_TOKEN_TTL_MS is invalid', () => {
process.env.SESSION_TOKEN_TTL_MS = 'not-a-number';
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
});
});

View File

@@ -4,7 +4,7 @@ import { SessionTokenEntity } from '../entities/SessionTokenEntity';
import { getUserById } from '../cqrs'; import { getUserById } from '../cqrs';
import type { AuthUserPayload } from '../cqrs/types'; import type { AuthUserPayload } from '../cqrs/types';
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
export interface IssuedSessionToken { export interface IssuedSessionToken {
token: string; token: string;

View File

@@ -0,0 +1,102 @@
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId, findVoiceActiveConnection } from './broadcast';
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 user: ConnectedUser = {
oderId,
ws: createMockWs(),
authenticated: true,
serverIds: new Set(['server-1']),
displayName: 'Test User',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
describe('broadcastToServer', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('delivers chat_message to every connection in the server except the sender connection', () => {
createConnectedUser('conn-a1', 'user-1');
const connA2 = createConnectedUser('conn-a2', 'user-1');
const connB = createConnectedUser('conn-b', 'user-2');
broadcastToServer('server-1', { type: 'chat_message', text: 'hello' }, {
excludeConnectionId: 'conn-a1'
});
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
});
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
const connA1 = createConnectedUser('conn-a1', 'user-1');
const connA2 = createConnectedUser('conn-a2', 'user-1');
const connB = createConnectedUser('conn-b', 'user-2');
broadcastToServer('server-1', { type: 'user_left', oderId: 'user-1' }, {
excludeIdentityOderId: 'user-1'
});
expect((connA1.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
});
});
describe('findVoiceActiveConnection', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('returns the connection marked voiceActive for the user', () => {
createConnectedUser('conn-passive', 'user-1', { voiceActive: false });
const active = createConnectedUser('conn-active', 'user-1', { voiceActive: true });
expect(findVoiceActiveConnection('user-1')).toBe(active);
});
it('returns undefined when no voiceActive connection exists', () => {
createConnectedUser('conn-1', 'user-1');
expect(findVoiceActiveConnection('user-1')).toBeUndefined();
});
it('findUserByOderId falls back to any open connection when no voiceActive connection exists', () => {
const fallback = createConnectedUser('conn-1', 'user-1');
expect(findUserByOderId('user-1')).toBe(fallback);
});
});

View File

@@ -7,19 +7,35 @@ interface WsMessage {
type: string; type: string;
} }
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void { export interface BroadcastOptions {
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); /** Skip only the sending WebSocket connection. */
excludeConnectionId?: string;
/** Skip every open connection for this identity (presence events). */
excludeIdentityOderId?: string;
}
// Deduplicate by oderId so users with multiple connections (e.g. from export function broadcastToServer(serverId: string, message: WsMessage, options?: BroadcastOptions): void {
// different signal URLs routing to the same server) receive the console.log(
// broadcast only once. `Broadcasting to server ${serverId}, excluding connection ${options?.excludeConnectionId ?? 'none'} ` +
const sentToOderIds = new Set<string>(); `identity ${options?.excludeIdentityOderId ?? 'none'}:`,
message.type
);
connectedUsers.forEach((user) => { connectedUsers.forEach((user, connectionId) => {
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) { if (
sentToOderIds.add(user.oderId); !user.serverIds.has(serverId)
console.log(` -> Sending to ${user.displayName} (${user.oderId})`); || connectionId === options?.excludeConnectionId
|| (options?.excludeIdentityOderId && user.oderId === options.excludeIdentityOderId)
|| user.ws.readyState !== WebSocket.OPEN
) {
return;
}
try {
console.log(` -> Sending to ${user.displayName} (${user.oderId}) via ${connectionId}`);
user.ws.send(JSON.stringify(message)); user.ws.send(JSON.stringify(message));
} catch (error) {
console.warn(`Failed to broadcast ${message.type} to ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
} }
}); });
} }
@@ -77,7 +93,45 @@ export function notifyUser(oderId: string, message: WsMessage): void {
} }
} }
export function notifyOtherConnectionsForOderId(
oderId: string,
message: WsMessage,
excludeConnectionId?: string
): void {
connectedUsers.forEach((user, connectionId) => {
if (
connectionId === excludeConnectionId
|| user.oderId !== oderId
|| user.ws.readyState !== WebSocket.OPEN
) {
return;
}
try {
user.ws.send(JSON.stringify(message));
} catch (error) {
console.warn(`Failed to notify ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
}
});
}
export function findUserByOderId(oderId: string) { export function findUserByOderId(oderId: string) {
return findVoiceActiveConnection(oderId) ?? findAnyConnectionForOderId(oderId);
}
export function findVoiceActiveConnection(oderId: string): ConnectedUser | undefined {
let voiceActiveMatch: ConnectedUser | undefined;
connectedUsers.forEach((user) => {
if (user.oderId === oderId && user.voiceActive && user.ws.readyState === WebSocket.OPEN) {
voiceActiveMatch = user;
}
});
return voiceActiveMatch;
}
export function findAnyConnectionForOderId(oderId: string): ConnectedUser | undefined {
let match: ConnectedUser | undefined; let match: ConnectedUser | undefined;
connectedUsers.forEach((user) => { connectedUsers.forEach((user) => {

View File

@@ -0,0 +1,219 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { handleWebSocketMessage } from './handler';
vi.mock('../services/server-access.service', () => ({
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
usersShareServerMembership: vi.fn(async () => true)
}));
vi.mock('../services/session-auth.service', () => ({
consumeSessionToken: vi.fn(async (token: string) => {
if (token !== 'test-token') {
return null;
}
return {
token,
user: {
id: 'user-1',
username: 'alice',
displayName: 'Alice',
passwordHash: 'hash',
createdAt: Date.now()
},
issuedAt: Date.now(),
expiresAt: Date.now() + 60_000
};
})
}));
vi.mock('../services/plugin-support.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
return {
...actual,
getPluginRequirementsSnapshot: vi.fn(async () => ({
requirements: [],
eventDefinitions: []
}))
};
});
function createMockWs(): WebSocket & { sentMessages: string[]; closeCalled: boolean } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => { ws.closeCalled = true; },
terminate: () => { ws.closeCalled = true; },
closeCalled: false,
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[]; closeCalled: boolean };
return ws;
}
function createConnectedUser(
connectionId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const ws = createMockWs();
const user: ConnectedUser = {
oderId: connectionId,
ws,
authenticated: false,
serverIds: new Set(),
displayName: 'Alice',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
function getSentMessages(user: ConnectedUser): string[] {
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
}
describe('server websocket handler - multi-client sessions', () => {
beforeEach(() => {
connectedUsers.clear();
vi.clearAllMocks();
});
it('relays voice_state to other connections for the same user', async () => {
const sender = createConnectedUser('conn-a1', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-a'
});
const passive = createConnectedUser('conn-a2', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-b'
});
getSentMessages(passive).length = 0;
await handleWebSocketMessage('conn-a1', {
type: 'voice_state',
serverId: 'server-1',
voiceState: {
isConnected: true,
roomId: 'voice-1',
serverId: 'server-1',
clientInstanceId: 'device-a'
}
});
const messages = getSentMessages(passive).map((raw) => JSON.parse(raw) as { type: string });
const voiceState = messages.find((message) => message.type === 'voice_state');
expect(voiceState).toBeDefined();
expect(connectedUsers.get('conn-a1')?.voiceActive).toBe(true);
expect(connectedUsers.get('conn-a2')?.voiceActive).toBeFalsy();
});
it('forwards RTC offers to the voice-active connection for the target user', async () => {
const sender = createConnectedUser('conn-sender', {
authenticated: true,
oderId: 'user-2',
serverIds: new Set(['server-1'])
});
createConnectedUser('conn-passive', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-passive'
});
const active = createConnectedUser('conn-active', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
voiceActive: true,
clientInstanceId: 'device-active'
});
getSentMessages(active).length = 0;
await handleWebSocketMessage('conn-sender', {
type: 'offer',
targetUserId: 'user-1',
serverId: 'server-1',
payload: { sdp: { type: 'offer', sdp: 'v=0' } }
});
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string });
expect(messages.some((message) => message.type === 'offer')).toBe(true);
});
it('relays voice_client_takeover to other connections for the same user', async () => {
createConnectedUser('conn-requester', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
clientInstanceId: 'device-b'
});
const active = createConnectedUser('conn-active', {
authenticated: true,
oderId: 'user-1',
serverIds: new Set(['server-1']),
voiceActive: true,
clientInstanceId: 'device-a'
});
getSentMessages(active).length = 0;
await handleWebSocketMessage('conn-requester', {
type: 'voice_client_takeover',
clientInstanceId: 'device-b'
});
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string; clientInstanceId?: string });
const takeover = messages.find((message) => message.type === 'voice_client_takeover');
expect(takeover?.clientInstanceId).toBe('device-b');
});
it('evicts a stale connection with the same identity scope and client instance', async () => {
const stale = createConnectedUser('conn-stale', {
authenticated: true,
oderId: 'user-1',
connectionScope: 'ws://localhost:3001',
clientInstanceId: 'device-a'
});
createConnectedUser('conn-new', {
authenticated: false,
connectionScope: 'ws://localhost:3001',
clientInstanceId: 'device-a'
});
await handleWebSocketMessage('conn-new', {
type: 'identify',
token: 'test-token',
oderId: 'user-1',
displayName: 'Alice',
connectionScope: 'ws://localhost:3001',
clientInstanceId: 'device-a'
});
expect(connectedUsers.has('conn-stale')).toBe(false);
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
});
});

View File

@@ -5,7 +5,8 @@ import {
findUserByOderId, findUserByOderId,
getServerIdsForOderId, getServerIdsForOderId,
getUniqueUsersInServer, getUniqueUsersInServer,
isOderIdConnectedToServer isOderIdConnectedToServer,
notifyOtherConnectionsForOderId
} from './broadcast'; } from './broadcast';
import { import {
authorizeWebSocketJoin, authorizeWebSocketJoin,
@@ -72,6 +73,74 @@ function buildPresenceUserPayload(user: ConnectedUser): {
}; };
} }
function normalizeClientInstanceId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
}
function readVoiceConnected(message: WsMessage): boolean {
const voiceState = message['voiceState'];
if (!voiceState || typeof voiceState !== 'object') {
return message['isConnected'] === true;
}
return (voiceState as { isConnected?: boolean }).isConnected === true;
}
function evictStaleClientInstanceConnections(
oderId: string,
connectionScope: string | undefined,
clientInstanceId: string | undefined,
keepConnectionId: string
): void {
if (!clientInstanceId) {
return;
}
connectedUsers.forEach((candidate, connectionId) => {
if (
connectionId === keepConnectionId
|| candidate.oderId !== oderId
|| candidate.connectionScope !== connectionScope
|| candidate.clientInstanceId !== clientInstanceId
) {
return;
}
try {
candidate.ws.close();
} catch {
console.warn(`Failed to close stale connection ${connectionId} for ${oderId}`);
}
connectedUsers.delete(connectionId);
});
}
function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string): void {
connectedUsers.forEach((candidate, connectionId) => {
if (candidate.oderId !== oderId || connectionId === exceptConnectionId) {
return;
}
candidate.voiceActive = false;
connectedUsers.set(connectionId, candidate);
});
}
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
user.ws.send(JSON.stringify({
type: 'voice_state',
...snapshot
}));
}
function readMessageId(value: unknown): string | undefined { function readMessageId(value: unknown): string | undefined {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return undefined; return undefined;
@@ -198,13 +267,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
const newOderId = session.user.id; const newOderId = session.user.id;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
const newClientInstanceId = normalizeClientInstanceId(message['clientInstanceId']);
const previousDisplayName = normalizeDisplayName(user.displayName); const previousDisplayName = normalizeDisplayName(user.displayName);
const previousDescription = user.description; const previousDescription = user.description;
const previousProfileUpdatedAt = user.profileUpdatedAt; const previousProfileUpdatedAt = user.profileUpdatedAt;
const previousHomeSignalServerUrl = user.homeSignalServerUrl; const previousHomeSignalServerUrl = user.homeSignalServerUrl;
evictStaleClientInstanceConnections(newOderId, newScope, newClientInstanceId, connectionId);
user.oderId = newOderId; user.oderId = newOderId;
user.authenticated = true; user.authenticated = true;
user.clientInstanceId = newClientInstanceId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
if (Object.prototype.hasOwnProperty.call(message, 'description')) { if (Object.prototype.hasOwnProperty.call(message, 'description')) {
@@ -223,6 +296,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
otherConnectionId !== connectionId
&& otherUser.oderId === newOderId
&& otherUser.voiceActive
&& otherUser.voiceStateSnapshot
)?.[1]?.voiceStateSnapshot;
if (voiceSnapshot) {
sendVoiceStateSnapshotToConnection(user, voiceSnapshot);
}
if ( if (
user.displayName === previousDisplayName user.displayName === previousDisplayName
&& user.description === previousDescription && user.description === previousDescription
@@ -240,7 +324,7 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
...buildPresenceUserPayload(user), ...buildPresenceUserPayload(user),
serverId serverId
}, },
user.oderId { excludeConnectionId: connectionId }
); );
} }
} }
@@ -287,7 +371,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
...buildPresenceUserPayload(user), ...buildPresenceUserPayload(user),
serverId: sid serverId: sid
}, },
user.oderId { excludeIdentityOderId: user.oderId }
); );
} }
} }
@@ -338,7 +422,7 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
serverId: leaveSid, serverId: leaveSid,
serverIds: remainingServerIds serverIds: remainingServerIds
}, },
user.oderId { excludeIdentityOderId: user.oderId }
); );
} }
@@ -394,7 +478,7 @@ async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promi
} }
} }
function handleChatMessage(user: ConnectedUser, message: WsMessage): void { function handleChatMessage(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
if (chatSid && user.serverIds.has(chatSid)) { if (chatSid && user.serverIds.has(chatSid)) {
@@ -404,18 +488,38 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
message: message['message'], message: message['message'],
senderId: user.oderId, senderId: user.oderId,
senderName: user.displayName, senderName: user.displayName,
clientInstanceId: user.clientInstanceId,
timestamp: Date.now() timestamp: Date.now()
}); }, { excludeConnectionId: connectionId });
} }
} }
function handleVoiceState(user: ConnectedUser, message: WsMessage): void { function handleVoiceState(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId; const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!serverId || !user.serverIds.has(serverId)) { if (!serverId || !user.serverIds.has(serverId)) {
return; return;
} }
const isConnected = readVoiceConnected(message);
if (isConnected) {
clearVoiceActiveForOderId(user.oderId, connectionId);
user.voiceActive = true;
user.voiceStateSnapshot = {
...message,
type: 'voice_state',
serverId,
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName)
};
} else {
user.voiceActive = false;
user.voiceStateSnapshot = undefined;
}
connectedUsers.set(connectionId, user);
broadcastToServer( broadcastToServer(
serverId, serverId,
{ {
@@ -425,11 +529,19 @@ function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
oderId: user.oderId, oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName) displayName: normalizeDisplayName(user.displayName)
}, },
user.oderId { excludeConnectionId: connectionId }
); );
} }
function handleTyping(user: ConnectedUser, message: WsMessage): void { function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, connectionId: string): void {
notifyOtherConnectionsForOderId(user.oderId, {
type: 'voice_client_takeover',
clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId,
requestedByClientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId
}, connectionId);
}
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general'; const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
const isTyping = message['isTyping'] !== false; const isTyping = message['isTyping'] !== false;
@@ -443,9 +555,10 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
channelId, channelId,
isTyping, isTyping,
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName displayName: user.displayName,
clientInstanceId: user.clientInstanceId
}, },
user.oderId { excludeConnectionId: connectionId }
); );
} }
} }
@@ -475,7 +588,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
oderId: user.oderId, oderId: user.oderId,
status status
}, },
user.oderId { excludeConnectionId: connectionId }
); );
} }
} }
@@ -520,7 +633,7 @@ function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): v
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
} }
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> { async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId; const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
const pluginId = readMessageId(message['pluginId']); const pluginId = readMessageId(message['pluginId']);
const eventName = readMessageId(message['eventName']); const eventName = readMessageId(message['eventName']);
@@ -565,7 +678,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
sourceUserId: user.oderId, sourceUserId: user.oderId,
emittedAt: Date.now() emittedAt: Date.now()
}, },
user.oderId { excludeConnectionId: connectionId }
); );
} catch (error) { } catch (error) {
sendPluginError(user, error, message); sendPluginError(user, error, message);
@@ -623,15 +736,19 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
break; break;
case 'chat_message': case 'chat_message':
handleChatMessage(user, message); handleChatMessage(user, message, connectionId);
break; break;
case 'voice_state': case 'voice_state':
handleVoiceState(user, message); handleVoiceState(user, message, connectionId);
break;
case 'voice_client_takeover':
handleVoiceClientTakeover(user, message, connectionId);
break; break;
case 'typing': case 'typing':
handleTyping(user, message); handleTyping(user, message, connectionId);
break; break;
case 'status_update': case 'status_update':
@@ -647,7 +764,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
break; break;
case 'plugin_event': case 'plugin_event':
await handlePluginEvent(user, message); await handlePluginEvent(user, message, connectionId);
break; break;
default: default:

View File

@@ -39,7 +39,7 @@ function removeDeadConnection(connectionId: string): void {
displayName: user.displayName, displayName: user.displayName,
serverId: sid, serverId: sid,
serverIds: remainingServerIds serverIds: remainingServerIds
}, user.oderId); }, { excludeIdentityOderId: user.oderId });
}); });
try { try {

View File

@@ -22,6 +22,12 @@ export interface ConnectedUser {
status?: 'online' | 'away' | 'busy' | 'offline'; status?: 'online' | 'away' | 'busy' | 'offline';
/** Latest server icon timestamp this connection can provide over P2P. */ /** Latest server icon timestamp this connection can provide over P2P. */
serverIconUpdatedAtByServerId?: Map<string, number>; serverIconUpdatedAtByServerId?: Map<string, number>;
/** Stable per-install client id sent by the product client. */
clientInstanceId?: string;
/** Whether this connection currently owns active voice/WebRTC for the user. */
voiceActive?: boolean;
/** Cached voice state snapshot used to bootstrap newly connected client instances. */
voiceStateSnapshot?: Record<string, unknown>;
/** Timestamp of the last pong or client message received (used to detect dead connections). */ /** Timestamp of the last pong or client message received (used to detect dead connections). */
lastPong: number; lastPong: number;
} }

View File

@@ -24,6 +24,8 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" | | **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" | | **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" | | **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
| **Client instance** | Stable per-install UUID (`metoyou.clientInstanceId`) sent on WebSocket `identify` and voice-state payloads so the signaling server can route multi-device sessions. | "device id", "session id" |
| **Voice owner connection** | The single client instance whose `clientInstanceId` matches the user's active `voiceState.clientInstanceId` and therefore owns mic/WebRTC for that identity. | "active voice client" |
## Relationships ## Relationships

View File

@@ -35,7 +35,8 @@
"resizeChat": "Resize chat", "resizeChat": "Resize chat",
"yourCamera": "Your camera", "yourCamera": "Your camera",
"yourScreen": "Your screen", "yourScreen": "Your screen",
"waiting": "Waiting" "waiting": "Waiting",
"voiceOnOtherDevice": "Active on another device"
}, },
"notifications": { "notifications": {
"inProgress": "Call in progress" "inProgress": "Call in progress"

View File

@@ -33,6 +33,8 @@
"latencyMs": "{{ms}} ms", "latencyMs": "{{ms}} ms",
"playing": "Playing {{game}}", "playing": "Playing {{game}}",
"inVoice": "In voice", "inVoice": "In voice",
"voiceOnOtherDevice": "In voice on another device",
"takeOverVoice": "Join",
"plugins": "Plugins", "plugins": "Plugins",
"viewPlugins": "View plugins", "viewPlugins": "View plugins",
"you": "You", "you": "You",

View File

@@ -97,7 +97,8 @@
"resizeChat": "Resize chat", "resizeChat": "Resize chat",
"yourCamera": "Your camera", "yourCamera": "Your camera",
"yourScreen": "Your screen", "yourScreen": "Your screen",
"waiting": "Waiting" "waiting": "Waiting",
"voiceOnOtherDevice": "Active on another device"
}, },
"notifications": { "notifications": {
"inProgress": "Call in progress" "inProgress": "Call in progress"
@@ -768,6 +769,8 @@
"latencyMs": "{{ms}} ms", "latencyMs": "{{ms}} ms",
"playing": "Playing {{game}}", "playing": "Playing {{game}}",
"inVoice": "In voice", "inVoice": "In voice",
"voiceOnOtherDevice": "In voice on another device",
"takeOverVoice": "Join",
"plugins": "Plugins", "plugins": "Plugins",
"viewPlugins": "View plugins", "viewPlugins": "View plugins",
"you": "You", "you": "You",

View File

@@ -58,6 +58,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { ROOM_URL_PATTERN } from './core/constants'; import { ROOM_URL_PATTERN } from './core/constants';
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage'; import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
import { runWhenIdle } from './shared/rxjs'; import { runWhenIdle } from './shared/rxjs';
import { import {
ThemeNodeDirective, ThemeNodeDirective,
@@ -319,9 +320,7 @@ export class App implements OnInit, OnDestroy {
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {}); this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
} else { } else {
this.router.navigate(['/login'], { this.router.navigate(['/login'], {
queryParams: { queryParams: buildLoginReturnQueryParams(currentUrl)
returnUrl: currentUrl
}
}).catch(() => {}); }).catch(() => {});
} }
} }

View File

@@ -0,0 +1,43 @@
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import { ClientInstanceService } from './client-instance.service';
const STORAGE_KEY = 'metoyou.clientInstanceId';
describe('ClientInstanceService', () => {
const storage = new Map<string, string>();
beforeEach(() => {
storage.clear();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, value); },
removeItem: (key: string) => { storage.delete(key); }
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('creates and persists a stable client instance id', () => {
const service = new ClientInstanceService();
const first = service.getClientInstanceId();
const second = new ClientInstanceService().getClientInstanceId();
expect(first).toMatch(/^[0-9a-f-]{36}$/i);
expect(second).toBe(first);
expect(storage.get(STORAGE_KEY)).toBe(first);
});
it('reuses a stored client instance id', () => {
storage.set(STORAGE_KEY, 'device-123');
expect(new ClientInstanceService().getClientInstanceId()).toBe('device-123');
});
});

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
const STORAGE_KEY = 'metoyou.clientInstanceId';
@Injectable({ providedIn: 'root' })
export class ClientInstanceService {
private cachedId: string | null = null;
getClientInstanceId(): string {
if (this.cachedId) {
return this.cachedId;
}
const stored = this.readStoredId();
if (stored) {
this.cachedId = stored;
return stored;
}
const created = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, created);
this.cachedId = created;
return created;
}
private readStoredId(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)?.trim();
return raw || null;
} catch {
return null;
}
}
}

View File

@@ -244,6 +244,13 @@ export interface ElectronAppMetricsSnapshot {
processes: ElectronAppMetricsProcess[]; processes: ElectronAppMetricsProcess[];
} }
export interface ElectronPerfDiagEntry {
collectedAt: number;
source: 'main' | 'renderer';
type: string;
payload: Record<string, unknown>;
}
export interface ElectronApi { export interface ElectronApi {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -263,6 +270,8 @@ export interface ElectronApi {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>; getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
isPerfDiagEnabled?: () => Promise<boolean>;
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>; exportUserData: () => Promise<ExportUserDataResult>;

View File

@@ -1,3 +1,4 @@
export * from './platform.service'; export * from './platform.service';
export * from './external-link.service'; export * from './external-link.service';
export * from './viewport.service'; export * from './viewport.service';
export * from './client-instance.service';

View File

@@ -15,8 +15,8 @@ authentication/
│ └── authentication.model.ts LoginResponse interface │ └── authentication.model.ts LoginResponse interface
├── feature/ ├── feature/
│ ├── login/ Login form component │ ├── login/ Login form (`<form ngSubmit>`; autofocus + select-on-focus via shared directives)
│ ├── register/ Registration form component │ ├── register/ Registration form (same form-field UX as login)
│ └── user-bar/ Displays current user or login/register links │ └── user-bar/ Displays current user or login/register links
└── index.ts Barrel exports └── index.ts Barrel exports

View File

@@ -6,7 +6,54 @@ import {
} from 'vitest'; } from 'vitest';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { waitForAuthenticationOutcome } from './auth-navigation.rules'; import {
buildLoginReturnQueryParams,
resolveSafeReturnUrl,
waitForAuthenticationOutcome
} from './auth-navigation.rules';
describe('resolveSafeReturnUrl', () => {
it('returns the requested in-app path unchanged', () => {
expect(resolveSafeReturnUrl('/servers')).toBe('/servers');
expect(resolveSafeReturnUrl('/room/abc')).toBe('/room/abc');
});
it('unwraps nested login returnUrl chains to the original destination', () => {
const nested = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
expect(resolveSafeReturnUrl(nested)).toBe('/servers');
expect(resolveSafeReturnUrl(`/login?returnUrl=${encodeURIComponent(nested)}`)).toBe('/servers');
});
it('falls back to dashboard for auth-only return targets', () => {
expect(resolveSafeReturnUrl('/login')).toBe('/dashboard');
expect(resolveSafeReturnUrl('/register')).toBe('/dashboard');
expect(resolveSafeReturnUrl(null)).toBe('/dashboard');
});
it('rejects open redirects and protocol-relative paths', () => {
expect(resolveSafeReturnUrl('//evil.example/phish')).toBe('/dashboard');
expect(resolveSafeReturnUrl('https://evil.example/phish')).toBe('/dashboard');
});
});
describe('buildLoginReturnQueryParams', () => {
it('preserves a safe destination when redirecting from protected routes', () => {
expect(buildLoginReturnQueryParams('/servers')).toEqual({ returnUrl: '/servers' });
});
it('does not nest login returnUrl values', () => {
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Fservers')).toEqual({ returnUrl: '/servers' });
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers')).toEqual({
returnUrl: '/servers'
});
});
it('omits returnUrl when there is no meaningful destination', () => {
expect(buildLoginReturnQueryParams('/login')).toEqual({});
expect(buildLoginReturnQueryParams('/register')).toEqual({});
});
});
describe('waitForAuthenticationOutcome', () => { describe('waitForAuthenticationOutcome', () => {
it('resolves when authentication storage preparation succeeds', async () => { it('resolves when authentication storage preparation succeeds', async () => {

View File

@@ -8,10 +8,88 @@ import {
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import type { User } from '../../../../shared-kernel'; import type { User } from '../../../../shared-kernel';
export const DEFAULT_POST_AUTH_URL = '/dashboard';
const AUTH_ROUTE_PATHS = new Set(['/login', '/register']);
const MAX_RETURN_URL_DEPTH = 10;
export type AuthenticationOutcome = export type AuthenticationOutcome =
| { kind: 'success'; user: User } | { kind: 'success'; user: User }
| { kind: 'failure'; error: string }; | { kind: 'failure'; error: string };
export function isAuthRoutePath(path: string): boolean {
return AUTH_ROUTE_PATHS.has(path);
}
export function getRoutePathFromUrl(url: string): string {
if (!url) {
return '/';
}
const [path] = url.split(/[?#]/, 1);
return path || '/';
}
export function extractReturnUrlParam(url: string): string | null {
const queryStart = url.indexOf('?');
if (queryStart === -1) {
return null;
}
const hashStart = url.indexOf('#', queryStart + 1);
const query = hashStart === -1
? url.slice(queryStart + 1)
: url.slice(queryStart + 1, hashStart);
return new URLSearchParams(query).get('returnUrl');
}
export function resolveSafeReturnUrl(
url: string | null | undefined,
fallback = DEFAULT_POST_AUTH_URL
): string {
let candidate = url?.trim() ?? '';
let depth = 0;
while (candidate && depth < MAX_RETURN_URL_DEPTH) {
if (!candidate.startsWith('/') || candidate.startsWith('//')) {
return fallback;
}
const path = getRoutePathFromUrl(candidate);
if (!isAuthRoutePath(path)) {
return candidate;
}
const nestedReturnUrl = extractReturnUrlParam(candidate)?.trim();
if (!nestedReturnUrl) {
return fallback;
}
candidate = nestedReturnUrl;
depth += 1;
}
return fallback;
}
export function buildLoginReturnQueryParams(
currentUrl: string,
fallback = DEFAULT_POST_AUTH_URL
): { returnUrl?: string } {
const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback);
if (safeReturnUrl === fallback) {
return {};
}
return { returnUrl: safeReturnUrl };
}
export function waitForAuthenticationOutcome( export function waitForAuthenticationOutcome(
actions$: Observable<{ type: string; user?: User; error?: string }> actions$: Observable<{ type: string; user?: User; error?: string }>
): Observable<AuthenticationOutcome> { ): Observable<AuthenticationOutcome> {

View File

@@ -8,7 +8,10 @@
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1> <h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1>
</div> </div>
<div class="space-y-3"> <form
class="space-y-3"
(ngSubmit)="submit()"
>
<div> <div>
<label <label
for="login-username" for="login-username"
@@ -19,6 +22,9 @@
[(ngModel)]="username" [(ngModel)]="username"
type="text" type="text"
id="login-username" id="login-username"
name="username"
appAutoFocus
appSelectOnFocus
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
@@ -32,6 +38,7 @@
[(ngModel)]="password" [(ngModel)]="password"
type="password" type="password"
id="login-password" id="login-password"
name="password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
@@ -44,6 +51,7 @@
<select <select
[(ngModel)]="serverId" [(ngModel)]="serverId"
id="login-server" id="login-server"
name="serverId"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
> >
@for (s of servers(); track s.id) { @for (s of servers(); track s.id) {
@@ -55,22 +63,21 @@
<p class="text-xs text-destructive">{{ error() }}</p> <p class="text-xs text-destructive">{{ error() }}</p>
} }
<button <button
(click)="submit()" type="submit"
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
> >
{{ 'auth.login.submit' | translate }} {{ 'auth.login.submit' | translate }}
</button> </button>
<div class="text-xs text-muted-foreground text-center mt-2"> </form>
{{ 'auth.login.noAccount' | translate }} <div class="text-xs text-muted-foreground text-center mt-2">
<button {{ 'auth.login.noAccount' | translate }}
type="button" <button
(click)="goRegister()" type="button"
class="text-primary hover:underline" (click)="goRegister()"
> class="text-primary hover:underline"
{{ 'auth.login.registerLink' | translate }} >
</button> {{ 'auth.login.registerLink' | translate }}
</div> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { import {
Component, Component,
inject, inject,
OnInit,
signal signal
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -11,14 +12,24 @@ import { Actions } from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide'; import { lucideLogIn } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs'; import {
filter,
firstValueFrom,
take
} from 'rxjs';
import { AuthenticationService } from '../../application/services/authentication.service'; import { AuthenticationService } from '../../application/services/authentication.service';
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules'; import {
buildLoginReturnQueryParams,
resolveSafeReturnUrl,
waitForAuthenticationOutcome
} from '../../domain/logic/auth-navigation.rules';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@@ -27,6 +38,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
CommonModule, CommonModule,
FormsModule, FormsModule,
NgIcon, NgIcon,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [provideIcons({ lucideLogIn })], viewProviders: [provideIcons({ lucideLogIn })],
@@ -35,7 +48,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
/** /**
* Login form allowing existing users to authenticate against a selected server. * Login form allowing existing users to authenticate against a selected server.
*/ */
export class LoginComponent { export class LoginComponent implements OnInit {
serversSvc = inject(ServerDirectoryFacade); serversSvc = inject(ServerDirectoryFacade);
servers = this.serversSvc.servers; servers = this.serversSvc.servers;
@@ -54,6 +67,18 @@ export class LoginComponent {
/** TrackBy function for server list rendering. */ /** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; } trackById(_index: number, item: { id: string }) { return item.id; }
ngOnInit(): void {
this.store.select(selectCurrentUser).pipe(
filter(Boolean),
take(1)
)
.subscribe(() => {
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
void this.router.navigateByUrl(returnUrl);
});
}
/** Validate and submit the login form, then navigate to search on success. */ /** Validate and submit the login form, then navigate to search on success. */
submit() { submit() {
this.error.set(null); this.error.set(null);
@@ -88,14 +113,9 @@ export class LoginComponent {
return; return;
} }
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
if (returnUrl?.startsWith('/')) { await this.router.navigateByUrl(returnUrl);
await this.router.navigateByUrl(returnUrl);
return;
}
await this.router.navigate(['/dashboard']);
}, },
error: (err) => { error: (err) => {
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed')); this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
@@ -105,10 +125,8 @@ export class LoginComponent {
/** Navigate to the registration page. */ /** Navigate to the registration page. */
goRegister() { goRegister() {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
this.router.navigate(['/register'], { this.router.navigate(['/register'], {
queryParams: returnUrl ? { returnUrl } : undefined queryParams: buildLoginReturnQueryParams(this.router.url)
}); });
} }
} }

View File

@@ -8,7 +8,10 @@
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1> <h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1>
</div> </div>
<div class="space-y-3"> <form
class="space-y-3"
(ngSubmit)="submit()"
>
<div> <div>
<label <label
for="register-username" for="register-username"
@@ -19,6 +22,9 @@
[(ngModel)]="username" [(ngModel)]="username"
type="text" type="text"
id="register-username" id="register-username"
name="username"
appAutoFocus
appSelectOnFocus
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
@@ -32,6 +38,8 @@
[(ngModel)]="displayName" [(ngModel)]="displayName"
type="text" type="text"
id="register-display-name" id="register-display-name"
name="displayName"
appSelectOnFocus
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
@@ -45,6 +53,7 @@
[(ngModel)]="password" [(ngModel)]="password"
type="password" type="password"
id="register-password" id="register-password"
name="password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
@@ -57,6 +66,7 @@
<select <select
[(ngModel)]="serverId" [(ngModel)]="serverId"
id="register-server" id="register-server"
name="serverId"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
> >
@for (s of servers(); track s.id) { @for (s of servers(); track s.id) {
@@ -68,22 +78,21 @@
<p class="text-xs text-destructive">{{ error() }}</p> <p class="text-xs text-destructive">{{ error() }}</p>
} }
<button <button
(click)="submit()" type="submit"
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
> >
{{ 'auth.register.submit' | translate }} {{ 'auth.register.submit' | translate }}
</button> </button>
<div class="text-xs text-muted-foreground text-center mt-2"> </form>
{{ 'auth.register.haveAccount' | translate }} <div class="text-xs text-muted-foreground text-center mt-2">
<button {{ 'auth.register.haveAccount' | translate }}
type="button" <button
(click)="goLogin()" type="button"
class="text-primary hover:underline" (click)="goLogin()"
> class="text-primary hover:underline"
{{ 'auth.register.loginLink' | translate }} >
</button> {{ 'auth.register.loginLink' | translate }}
</div> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -15,10 +15,15 @@ import { firstValueFrom } from 'rxjs';
import { AuthenticationService } from '../../application/services/authentication.service'; import { AuthenticationService } from '../../application/services/authentication.service';
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules'; import {
buildLoginReturnQueryParams,
resolveSafeReturnUrl,
waitForAuthenticationOutcome
} from '../../domain/logic/auth-navigation.rules';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
@@ -27,6 +32,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
CommonModule, CommonModule,
FormsModule, FormsModule,
NgIcon, NgIcon,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [provideIcons({ lucideUserPlus })], viewProviders: [provideIcons({ lucideUserPlus })],
@@ -90,14 +97,9 @@ export class RegisterComponent {
return; return;
} }
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
if (returnUrl?.startsWith('/')) { await this.router.navigateByUrl(returnUrl);
await this.router.navigateByUrl(returnUrl);
return;
}
await this.router.navigate(['/dashboard']);
}, },
error: (err) => { error: (err) => {
this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed')); this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed'));
@@ -107,10 +109,8 @@ export class RegisterComponent {
/** Navigate to the login page. */ /** Navigate to the login page. */
goLogin() { goLogin() {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
this.router.navigate(['/login'], { this.router.navigate(['/login'], {
queryParams: returnUrl ? { returnUrl } : undefined queryParams: buildLoginReturnQueryParams(this.router.url)
}); });
} }
} }

View File

@@ -12,6 +12,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { import {
merge, merge,
interval, interval,
@@ -47,6 +48,7 @@ export class TypingIndicatorComponent {
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private lastRoomId: string | null = null; private lastRoomId: string | null = null;
private lastConversationKey: string | null = null; private lastConversationKey: string | null = null;
@@ -145,9 +147,22 @@ export class TypingIndicatorComponent {
private recomputeDisplay(): void { private recomputeDisplay(): void {
const now = Date.now(); const now = Date.now();
const activeChannelId = this.activeChannelId() ?? 'general'; const activeChannelId = this.activeChannelId() ?? 'general';
const names = Array.from(this.typingMap.values()) const currentUserId = this.currentUser()?.id || this.currentUser()?.oderId;
.filter((entry) => entry.expiresAt > now && entry.channelId === activeChannelId) const names = Array.from(this.typingMap.entries())
.map((e) => e.name); .filter(([typingKey, entry]) => {
if (entry.expiresAt <= now || entry.channelId !== activeChannelId) {
return false;
}
if (!currentUserId) {
return true;
}
const [, oderId] = typingKey.split(':');
return oderId !== currentUserId;
})
.map(([, entry]) => entry.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN)); this.typingDisplay.set(names.slice(0, MAX_SHOWN));
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN)); this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));

View File

@@ -72,6 +72,8 @@
/> />
<input <input
type="search" type="search"
appAutoFocus
appSelectOnFocus
class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="{{ 'emoji.picker.searchPlaceholder' | translate }}" placeholder="{{ 'emoji.picker.searchPlaceholder' | translate }}"
aria-label="{{ 'emoji.picker.searchAria' | translate }}" aria-label="{{ 'emoji.picker.searchAria' | translate }}"

View File

@@ -22,6 +22,10 @@ import {
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel'; import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service'; import { CustomEmojiService } from '../../application/custom-emoji.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective
} from '../../../../shared/directives';
import { import {
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE, CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
UNICODE_EMOJI_PICKER_ENTRIES, UNICODE_EMOJI_PICKER_ENTRIES,
@@ -34,7 +38,13 @@ import {
@Component({ @Component({
selector: 'app-custom-emoji-picker', selector: 'app-custom-emoji-picker',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })], viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })],
templateUrl: './custom-emoji-picker.component.html' templateUrl: './custom-emoji-picker.component.html'
}) })

View File

@@ -21,7 +21,11 @@ import {
VoiceConnectionFacade, VoiceConnectionFacade,
VoicePlaybackService VoicePlaybackService
} from '../../../voice-connection'; } from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session'; import {
VoiceSessionFacade,
isVoiceOnAnotherClient
} from '../../../voice-session';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message'; import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import type { DirectMessageConversation } from '../../../direct-message'; import type { DirectMessageConversation } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
@@ -43,6 +47,7 @@ export class DirectCallService {
private readonly audio = inject(NotificationAudioService); private readonly audio = inject(NotificationAudioService);
private readonly voice = inject(VoiceConnectionFacade); private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceSession = inject(VoiceSessionFacade); private readonly voiceSession = inject(VoiceSessionFacade);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService); private readonly playback = inject(VoicePlaybackService);
private readonly viewport = inject(ViewportService); private readonly viewport = inject(ViewportService);
@@ -325,6 +330,11 @@ export class DirectCallService {
this.leaveCurrentVoiceTargetForCall(callId); this.leaveCurrentVoiceTargetForCall(callId);
this.audio.stop(AppSound.Call); this.audio.stop(AppSound.Call);
if (isVoiceOnAnotherClient(me.voiceState, this.realtime.getClientInstanceId())) {
this.realtime.requestVoiceClientTakeover();
await new Promise((resolve) => window.setTimeout(resolve, 300));
}
const ok = await this.voice.ensureSignalingConnected(); const ok = await this.voice.ensureSignalingConnected();
if (!ok || !navigator.mediaDevices?.getUserMedia) { if (!ok || !navigator.mediaDevices?.getUserMedia) {
@@ -941,7 +951,8 @@ export class DirectCallService {
isMuted: connected ? this.voice.isMuted() : false, isMuted: connected ? this.voice.isMuted() : false,
isDeafened: connected ? this.voice.isDeafened() : false, isDeafened: connected ? this.voice.isDeafened() : false,
roomId: connected ? session.callId : undefined, roomId: connected ? session.callId : undefined,
serverId: connected ? session.callId : undefined serverId: connected ? session.callId : undefined,
clientInstanceId: connected ? this.realtime.getClientInstanceId() : undefined
} }
})); }));
} }

View File

@@ -25,6 +25,8 @@
/> />
<input <input
type="text" type="text"
appAutoFocus
appSelectOnFocus
[attr.aria-label]="'dm.find.searchAriaLabel' | translate" [attr.aria-label]="'dm.find.searchAriaLabel' | translate"
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="'dm.find.searchPlaceholder' | translate" [placeholder]="'dm.find.searchPlaceholder' | translate"

View File

@@ -17,6 +17,10 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective
} from '../../../../shared/directives';
import { UserSearchListComponent } from '../user-search-list/user-search-list.component'; import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
import { selectAllUsers } from '../../../../store/users/users.selectors'; import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
@@ -35,6 +39,8 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
RouterLink, RouterLink,
NgIcon, NgIcon,
UserSearchListComponent, UserSearchListComponent,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })], viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],

View File

@@ -74,8 +74,11 @@
<label class="relative flex min-w-0 flex-1"> <label class="relative flex min-w-0 flex-1">
<input <input
type="text" type="text"
appAutoFocus
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addSourceUrl()"
[(ngModel)]="newSourceUrl" [(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
[placeholder]="'plugins.store.sourcePlaceholder' | translate" [placeholder]="'plugins.store.sourcePlaceholder' | translate"
[attr.aria-label]="'plugins.store.sourceAria' | translate" [attr.aria-label]="'plugins.store.sourceAria' | translate"
class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
@@ -244,6 +247,7 @@
/> />
<input <input
type="search" type="search"
appSelectOnFocus
[ngModel]="searchTerm()" [ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)" (ngModelChange)="searchTerm.set($event)"
[placeholder]="'plugins.store.searchPlaceholder' | translate" [placeholder]="'plugins.store.searchPlaceholder' | translate"

View File

@@ -27,6 +27,7 @@ import {
lucideX lucideX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ExternalLinkService } from '../../../../core/platform'; import { ExternalLinkService } from '../../../../core/platform';
import { resolveSafeReturnUrl, getRoutePathFromUrl } from '../../../authentication/domain/logic/auth-navigation.rules';
import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { ChatMessageMarkdownComponent } from '../../../chat'; import { ChatMessageMarkdownComponent } from '../../../chat';
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control'; import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
@@ -40,6 +41,11 @@ import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/roo
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ModalBackdropComponent } from '../../../../shared'; import { ModalBackdropComponent } from '../../../../shared';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service'; import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginStoreService } from '../../application/services/plugin-store.service'; import { PluginStoreService } from '../../application/services/plugin-store.service';
import type { import type {
@@ -64,6 +70,9 @@ interface ServerPluginInstallDialog {
ChatMessageMarkdownComponent, ChatMessageMarkdownComponent,
NgIcon, NgIcon,
ModalBackdropComponent, ModalBackdropComponent,
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [
@@ -598,13 +607,14 @@ export class PluginStoreComponent implements OnInit {
} }
private getReturnUrl(): string { private getReturnUrl(): string {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl'); const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
const path = getRoutePathFromUrl(returnUrl);
if (returnUrl?.startsWith('/') && !returnUrl.startsWith('//') && !returnUrl.startsWith('/plugin-store')) { if (path.startsWith('/plugin-store')) {
return returnUrl; return '/dashboard';
} }
return '/dashboard'; return returnUrl;
} }
private canManageServerPlugins(room: Room, user: User): boolean { private canManageServerPlugins(room: Room, user: User): boolean {

View File

@@ -19,10 +19,10 @@
{{ 'common.actions.cancel' | translate }} {{ 'common.actions.cancel' | translate }}
</button> </button>
<button <button
type="button" type="submit"
form="create-server-dialog-form"
class="min-h-11 flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50" class="min-h-11 flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
[disabled]="!canCreate" [disabled]="!canCreate"
(click)="create()"
> >
{{ 'servers.create.submit' | translate }} {{ 'servers.create.submit' | translate }}
</button> </button>
@@ -60,10 +60,10 @@
</button> </button>
<button <button
id="create-server-dialog-submit" id="create-server-dialog-submit"
type="button" type="submit"
form="create-server-dialog-form"
class="flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50" class="flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
[disabled]="!canCreate" [disabled]="!canCreate"
(click)="create()"
> >
{{ 'servers.create.submit' | translate }} {{ 'servers.create.submit' | translate }}
</button> </button>
@@ -73,7 +73,11 @@
<!-- Shared form body for both presentations. --> <!-- Shared form body for both presentations. -->
<ng-template #form> <ng-template #form>
<div class="space-y-5 p-4"> <form
id="create-server-dialog-form"
class="space-y-5 p-4"
(ngSubmit)="create()"
>
<div> <div>
<span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span> <span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -104,6 +108,9 @@
<input <input
id="create-server-dialog-name" id="create-server-dialog-name"
type="text" type="text"
name="serverName"
appAutoFocus
appSelectOnFocus
[ngModel]="name()" [ngModel]="name()"
(ngModelChange)="name.set($event)" (ngModelChange)="name.set($event)"
[placeholder]="'servers.create.namePlaceholder' | translate" [placeholder]="'servers.create.namePlaceholder' | translate"
@@ -119,6 +126,7 @@
> >
<textarea <textarea
id="create-server-dialog-description" id="create-server-dialog-description"
name="serverDescription"
[ngModel]="description()" [ngModel]="description()"
(ngModelChange)="description.set($event)" (ngModelChange)="description.set($event)"
[placeholder]="'servers.create.descriptionPlaceholder' | translate" [placeholder]="'servers.create.descriptionPlaceholder' | translate"
@@ -152,6 +160,8 @@
<input <input
id="create-server-dialog-topic" id="create-server-dialog-topic"
type="text" type="text"
name="serverTopic"
appSelectOnFocus
[ngModel]="topic()" [ngModel]="topic()"
(ngModelChange)="topic.set($event)" (ngModelChange)="topic.set($event)"
[placeholder]="'servers.create.topicPlaceholder' | translate" [placeholder]="'servers.create.topicPlaceholder' | translate"
@@ -167,6 +177,7 @@
> >
<select <select
id="create-server-dialog-signal-endpoint" id="create-server-dialog-signal-endpoint"
name="sourceId"
[ngModel]="sourceId()" [ngModel]="sourceId()"
(ngModelChange)="sourceId.set($event)" (ngModelChange)="sourceId.set($event)"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
@@ -182,6 +193,7 @@
<input <input
id="create-server-dialog-private" id="create-server-dialog-private"
type="checkbox" type="checkbox"
name="isPrivate"
[ngModel]="isPrivate()" [ngModel]="isPrivate()"
(ngModelChange)="isPrivate.set($event)" (ngModelChange)="isPrivate.set($event)"
class="h-4 w-4 rounded border-border bg-secondary" class="h-4 w-4 rounded border-border bg-secondary"
@@ -202,6 +214,7 @@
<input <input
id="create-server-dialog-password" id="create-server-dialog-password"
type="password" type="password"
name="serverPassword"
[ngModel]="password()" [ngModel]="password()"
(ngModelChange)="password.set($event)" (ngModelChange)="password.set($event)"
[placeholder]="'servers.create.passwordPlaceholder' | translate" [placeholder]="'servers.create.passwordPlaceholder' | translate"
@@ -212,5 +225,5 @@
</div> </div>
} }
</div> </div>
</div> </form>
</ng-template> </ng-template>

View File

@@ -14,11 +14,16 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown, lucideChevronUp } from '@ng-icons/lucide'; import { lucideChevronDown, lucideChevronUp } from '@ng-icons/lucide';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { ThemeNodeDirective } from '../../../theme'; import { ThemeNodeDirective } from '../../../theme';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared'; import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared';
import {
AutoFocusDirective,
SelectOnFocusDirective
} from '../../../../shared/directives';
import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component'; import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component';
/** /**
@@ -37,6 +42,8 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-
ThemeNodeDirective, ThemeNodeDirective,
BottomSheetComponent, BottomSheetComponent,
ModalBackdropComponent, ModalBackdropComponent,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [provideIcons({ lucideChevronDown, lucideChevronUp })], viewProviders: [provideIcons({ lucideChevronDown, lucideChevronUp })],
@@ -104,7 +111,9 @@ export class CreateServerDialogComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
return; return;
} }

View File

@@ -17,7 +17,10 @@
</header> </header>
<div class="min-h-0 flex-1 overflow-y-auto"> <div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full max-w-lg space-y-6 p-4 sm:p-6"> <form
class="mx-auto w-full max-w-lg space-y-6 p-4 sm:p-6"
(ngSubmit)="createServer()"
>
<div> <div>
<span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span> <span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -48,6 +51,9 @@
<input <input
id="create-server-name" id="create-server-name"
type="text" type="text"
name="serverName"
appAutoFocus
appSelectOnFocus
[ngModel]="name()" [ngModel]="name()"
(ngModelChange)="name.set($event)" (ngModelChange)="name.set($event)"
[placeholder]="'servers.create.namePlaceholder' | translate" [placeholder]="'servers.create.namePlaceholder' | translate"
@@ -63,6 +69,7 @@
> >
<textarea <textarea
id="create-server-description" id="create-server-description"
name="serverDescription"
[ngModel]="description()" [ngModel]="description()"
(ngModelChange)="description.set($event)" (ngModelChange)="description.set($event)"
[placeholder]="'servers.create.descriptionPlaceholder' | translate" [placeholder]="'servers.create.descriptionPlaceholder' | translate"
@@ -96,6 +103,8 @@
<input <input
id="create-server-topic" id="create-server-topic"
type="text" type="text"
name="serverTopic"
appSelectOnFocus
[ngModel]="topic()" [ngModel]="topic()"
(ngModelChange)="topic.set($event)" (ngModelChange)="topic.set($event)"
[placeholder]="'servers.create.topicPlaceholder' | translate" [placeholder]="'servers.create.topicPlaceholder' | translate"
@@ -111,6 +120,7 @@
> >
<select <select
id="create-server-signal-endpoint" id="create-server-signal-endpoint"
name="sourceId"
[(ngModel)]="sourceId" [(ngModel)]="sourceId"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
> >
@@ -125,6 +135,7 @@
<input <input
id="create-server-private" id="create-server-private"
type="checkbox" type="checkbox"
name="isPrivate"
[ngModel]="isPrivate()" [ngModel]="isPrivate()"
(ngModelChange)="isPrivate.set($event)" (ngModelChange)="isPrivate.set($event)"
class="h-4 w-4 rounded border-border bg-secondary" class="h-4 w-4 rounded border-border bg-secondary"
@@ -145,6 +156,7 @@
<input <input
id="create-server-password" id="create-server-password"
type="password" type="password"
name="serverPassword"
[ngModel]="password()" [ngModel]="password()"
(ngModelChange)="password.set($event)" (ngModelChange)="password.set($event)"
[placeholder]="'servers.create.passwordPlaceholder' | translate" [placeholder]="'servers.create.passwordPlaceholder' | translate"
@@ -167,14 +179,13 @@
</button> </button>
<button <button
id="create-server-submit" id="create-server-submit"
type="button" type="submit"
class="flex-1 rounded-lg bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50" class="flex-1 rounded-lg bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
[disabled]="!canCreate" [disabled]="!canCreate"
(click)="createServer()"
> >
{{ 'servers.create.submit' | translate }} {{ 'servers.create.submit' | translate }}
</button> </button>
</div> </div>
</div> </form>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,11 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
/** Preset categories that pre-fill the server topic to speed up creation. */ /** Preset categories that pre-fill the server topic to speed up creation. */
@@ -47,6 +51,8 @@ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [
FormsModule, FormsModule,
RouterLink, RouterLink,
NgIcon, NgIcon,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [provideIcons({ lucideArrowLeft, lucideChevronDown, lucideChevronUp })], viewProviders: [provideIcons({ lucideArrowLeft, lucideChevronDown, lucideChevronUp })],
@@ -56,6 +62,7 @@ export class CreateServerComponent implements OnInit {
private store = inject(Store); private store = inject(Store);
private router = inject(Router); private router = inject(Router);
private serverDirectory = inject(ServerDirectoryFacade); private serverDirectory = inject(ServerDirectoryFacade);
private currentUser = this.store.selectSignal(selectCurrentUser);
readonly categories = CATEGORY_PRESETS; readonly categories = CATEGORY_PRESETS;
activeEndpoints = this.serverDirectory.activeServers; activeEndpoints = this.serverDirectory.activeServers;
@@ -102,13 +109,19 @@ export class CreateServerComponent implements OnInit {
return; return;
} }
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
return; return;
} }
setStoredCurrentUserId(currentUserId);
this.store.dispatch( this.store.dispatch(
RoomsActions.createRoom({ RoomsActions.createRoom({
name: this.name().trim(), name: this.name().trim(),

View File

@@ -17,6 +17,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
@Component({ @Component({
selector: 'app-invite', selector: 'app-invite',
@@ -168,9 +169,7 @@ export class InviteComponent implements OnInit {
this.message.set(this.i18n.instant('servers.invite.messages.redirectingLogin')); this.message.set(this.i18n.instant('servers.invite.messages.redirectingLogin'));
await this.router.navigate(['/login'], { await this.router.navigate(['/login'], {
queryParams: { queryParams: buildLoginReturnQueryParams(this.router.url)
returnUrl: this.router.url
}
}); });
} }

View File

@@ -144,6 +144,8 @@
/> />
<input <input
type="text" type="text"
appAutoFocus
appSelectOnFocus
[attr.aria-label]="'servers.browser.search.ariaLabel' | translate" [attr.aria-label]="'servers.browser.search.ariaLabel' | translate"
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="resolvedSearchPlaceholder" [placeholder]="resolvedSearchPlaceholder"

View File

@@ -30,6 +30,9 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { import {
selectSearchResults, selectSearchResults,
@@ -93,6 +96,8 @@ export interface ServerDiscoverySection {
ConfirmDialogComponent, ConfirmDialogComponent,
LeaveServerDialogComponent, LeaveServerDialogComponent,
ModalBackdropComponent, ModalBackdropComponent,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [
@@ -260,13 +265,19 @@ export class ServerBrowserComponent implements OnInit {
} }
async joinServer(server: ServerInfo): Promise<void> { async joinServer(server: ServerInfo): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
return; return;
} }
setStoredCurrentUserId(currentUserId);
if (await this.isServerBanned(server)) { if (await this.isServerBanned(server)) {
this.bannedServerName.set(server.name); this.bannedServerName.set(server.name);
this.showBannedDialog.set(true); this.showBannedDialog.set(true);
@@ -492,14 +503,19 @@ export class ServerBrowserComponent implements OnInit {
password?: string, password?: string,
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {} options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
): Promise<void> { ): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser(); const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
return; return;
} }
setStoredCurrentUserId(currentUserId);
this.joinErrorMessage.set(null); this.joinErrorMessage.set(null);
this.joinPasswordError.set(null); this.joinPasswordError.set(null);

View File

@@ -86,7 +86,7 @@ A reactive `speakingMap` signal (a `Map<string, boolean>`) is published whenever
## Voice playback ## Voice playback
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline: `VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline. Pipelines are rebuilt only when that peer's live voice audio track set changes — composite remote-stream notifications (camera, screen share, SDP renegotiation) reuse the existing graph so AudioContexts are not churned.
```mermaid ```mermaid
graph LR graph LR

View File

@@ -0,0 +1,271 @@
import {
Injector,
runInInjectionContext,
signal,
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { ScreenShareFacade } from '../../../screen-share';
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
import { VoicePlaybackService } from './voice-playback.service';
let audioContextCount = 0;
describe('VoicePlaybackService', () => {
beforeEach(() => {
audioContextCount = 0;
installAudioDomMocks();
installLocalStorageMock();
});
it('creates one audio pipeline per peer on first connect', () => {
const context = createServiceContext({ isVoiceConnected: true });
const stream = createMockAudioStream(['track-a']);
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
expect(audioContextCount).toBe(1);
});
it('reuses the existing pipeline when the same voice stream is handled again', () => {
const context = createServiceContext({ isVoiceConnected: true });
const stream = createMockAudioStream(['track-a']);
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
expect(audioContextCount).toBe(1);
});
it('reuses the pipeline when only the MediaStream wrapper changes but live audio tracks are unchanged', () => {
const context = createServiceContext({ isVoiceConnected: true });
const track = createMockAudioTrack('track-a');
const firstStream = createMockAudioStreamFromTracks([track]);
const secondStream = createMockAudioStreamFromTracks([track]);
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
expect(audioContextCount).toBe(1);
});
it('rebuilds the pipeline when the live audio track set changes', () => {
const context = createServiceContext({ isVoiceConnected: true });
const firstStream = createMockAudioStream(['track-a']);
const secondStream = createMockAudioStream(['track-b']);
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
expect(audioContextCount).toBe(2);
});
it('does not recreate pipelines for unrelated remote stream notifications', () => {
const context = createServiceContext({ isVoiceConnected: true });
const voiceStream = createMockAudioStream(['track-a']);
context.voiceConnection.getRemoteVoiceStream.mockReturnValue(voiceStream);
context.service.handleRemoteStream('peer-1', voiceStream, connectedOptions());
context.remoteStream$.next({ peerId: 'peer-1', stream: createMockAudioStream(['screen-audio']) });
expect(audioContextCount).toBe(1);
});
it('creates pipelines for each peer when multiple friends join voice', () => {
const context = createServiceContext({ isVoiceConnected: true });
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-1']), connectedOptions());
context.service.handleRemoteStream('peer-2', createMockAudioStream(['track-2']), connectedOptions());
expect(audioContextCount).toBe(2);
});
it('still applies updated playback options when reusing an existing pipeline', () => {
const context = createServiceContext({ isVoiceConnected: true });
const stream = createMockAudioStream(['track-a']);
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 1 }));
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 0.5, isDeafened: true }));
expect(audioContextCount).toBe(1);
expect(context.service.getUserVolume('peer-1')).toBe(100);
});
});
interface ServiceContext {
service: VoicePlaybackService;
voiceConnection: {
isVoiceConnected: ReturnType<typeof signal<boolean>>;
onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>;
onVoiceConnected: Subject<void>;
onPeerDisconnected: Subject<string>;
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
getConnectedPeers: ReturnType<typeof vi.fn>;
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
};
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
}
function createServiceContext(options: { isVoiceConnected?: boolean } = {}): ServiceContext {
const remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
const voiceConnection = {
isVoiceConnected: signal(options.isVoiceConnected ?? false),
onRemoteStream: remoteStream$,
onVoiceConnected: new Subject<void>(),
onPeerDisconnected: new Subject<string>(),
getRemoteVoiceStream: vi.fn(() => null),
getConnectedPeers: vi.fn(() => []),
syncOutgoingVoiceRouting: vi.fn()
};
const screenShare = {
isScreenShareRemotePlaybackSuppressed: signal(false),
forceDefaultRemotePlaybackOutput: signal(false)
};
const store = {
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return signal(null);
}
if (selector === selectAllUsers) {
return signal([]);
}
throw new Error(`Unexpected selector in VoicePlaybackService test: ${String(selector)}`);
})
};
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
const effectScheduler = {
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
}),
flush: vi.fn(() => {
for (const scheduledEffect of scheduledEffects) {
if (scheduledEffect.dirty) {
scheduledEffect.run();
}
}
}),
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.delete(scheduledEffect);
}),
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
})
};
const injector = Injector.create({
providers: [
VoicePlaybackService,
{
provide: ChangeDetectionScheduler,
useValue: { notify: vi.fn() }
},
{ provide: EffectScheduler, useValue: effectScheduler },
{ provide: VoiceConnectionFacade, useValue: voiceConnection },
{ provide: ScreenShareFacade, useValue: screenShare },
{ provide: Store, useValue: store }
]
});
return {
service: runInInjectionContext(injector, () => injector.get(VoicePlaybackService)),
voiceConnection,
remoteStream$
};
}
function connectedOptions(overrides: Partial<{ outputVolume: number; isDeafened: boolean }> = {}) {
return {
isConnected: true,
outputVolume: overrides.outputVolume ?? 1,
isDeafened: overrides.isDeafened ?? false
};
}
function createMockAudioTrack(id: string, readyState: MediaStreamTrackState = 'live'): MediaStreamTrack {
return {
id,
kind: 'audio',
readyState,
enabled: true
} as MediaStreamTrack;
}
function createMockAudioStream(trackIds: string[]): MediaStream {
return createMockAudioStreamFromTracks(trackIds.map((id) => createMockAudioTrack(id)));
}
function createMockAudioStreamFromTracks(tracks: MediaStreamTrack[]): MediaStream {
return {
getAudioTracks: () => tracks,
getTracks: () => tracks
} as unknown as MediaStream;
}
function installAudioDomMocks(): void {
vi.stubGlobal('MediaStream', class MediaStream {
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
getAudioTracks(): MediaStreamTrack[] {
return this.tracks;
}
getTracks(): MediaStreamTrack[] {
return this.tracks;
}
});
vi.stubGlobal('Audio', class {
muted = false;
volume = 1;
srcObject: MediaStream | null = null;
play = vi.fn().mockResolvedValue(undefined);
remove = vi.fn();
});
vi.stubGlobal('AudioContext', class {
state = 'running';
close = vi.fn().mockResolvedValue(undefined);
constructor() {
audioContextCount += 1;
}
createGain() {
return {
gain: { value: 0 },
connect: vi.fn()
};
}
createMediaStreamSource() {
return { connect: vi.fn() };
}
createMediaStreamDestination() {
return { stream: createMockAudioStream(['destination']) };
}
});
}
function installLocalStorageMock(): void {
const storage = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
}
});
}

View File

@@ -112,10 +112,17 @@ export class VoicePlaybackService {
return; return;
} }
this.removePipeline(peerId);
this.rawRemoteStreams.set(peerId, stream);
this.masterVolume = options.outputVolume; this.masterVolume = options.outputVolume;
this.deafened = options.isDeafened; this.deafened = options.isDeafened;
if (this.shouldReusePipeline(peerId, stream)) {
this.rawRemoteStreams.set(peerId, stream);
this.applyGain(peerId);
return;
}
this.removePipeline(peerId);
this.rawRemoteStreams.set(peerId, stream);
this.createPipeline(peerId, stream); this.createPipeline(peerId, stream);
} }
@@ -142,13 +149,19 @@ export class VoicePlaybackService {
for (const peerId of peers) { for (const peerId of peers) {
const stream = this.voiceConnection.getRemoteVoiceStream(peerId); const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
if (stream && this.hasAudio(stream)) { if (!stream || !this.hasAudio(stream)) {
const trackedRaw = this.rawRemoteStreams.get(peerId); continue;
if (!trackedRaw || trackedRaw !== stream) {
this.handleRemoteStream(peerId, stream, options);
}
} }
if (this.shouldReusePipeline(peerId, stream)) {
this.masterVolume = options.outputVolume;
this.deafened = options.isDeafened;
this.rawRemoteStreams.set(peerId, stream);
this.applyGain(peerId);
continue;
}
this.handleRemoteStream(peerId, stream, options);
} }
} }
@@ -433,4 +446,41 @@ export class VoicePlaybackService {
private hasAudio(stream: MediaStream): boolean { private hasAudio(stream: MediaStream): boolean {
return stream.getAudioTracks().length > 0; return stream.getAudioTracks().length > 0;
} }
/**
* Remote composite-stream notifications (camera, screen share, renegotiation)
* can arrive without any change to the underlying voice audio tracks.
* Rebuilding the Web Audio graph in that case only churns AudioContexts.
*/
private shouldReusePipeline(peerId: string, stream: MediaStream): boolean {
const trackedRaw = this.rawRemoteStreams.get(peerId);
return this.peerPipelines.has(peerId)
&& !!trackedRaw
&& this.streamsShareLiveAudioTracks(trackedRaw, stream);
}
private streamsShareLiveAudioTracks(previous: MediaStream, next: MediaStream): boolean {
const previousTrackIds = this.getLiveAudioTrackIds(previous);
const nextTrackIds = this.getLiveAudioTrackIds(next);
if (previousTrackIds.length === 0 || nextTrackIds.length === 0) {
return false;
}
if (previousTrackIds.length !== nextTrackIds.length) {
return false;
}
const previousIds = new Set(previousTrackIds);
return nextTrackIds.every((trackId) => previousIds.has(trackId));
}
private getLiveAudioTrackIds(stream: MediaStream): string[] {
return stream
.getAudioTracks()
.filter((track) => track.readyState === 'live')
.map((track) => track.id);
}
} }

View File

@@ -82,6 +82,16 @@ When a voice session is active and the user navigates away from the voice-connec
Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another. Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another.
## Multi-device voice (Discord-style)
Each install has a stable `clientInstanceId` (`ClientInstanceService`). `VoiceState.clientInstanceId` records which device currently owns the microphone/WebRTC session for that user.
- **Local voice owner** — this device's `clientInstanceId` matches `voiceState.clientInstanceId`; mic, heartbeat, and WebRTC transmit run normally.
- **Passive client** — another device owns voice; this client still receives chat/presence and shows grayed "in voice on another device" UI in the room sidebar and private-call cards.
- **Takeover** — clicking **Join** on a passive client sends `voice_client_takeover` through signaling; the active device releases voice via `VoiceClientTakeoverService`, then the passive client completes a normal join.
Rules live in `domain/logic/client-voice-session.rules.ts`.
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session. Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move. Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.

View File

@@ -0,0 +1,68 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import type { User } from '../../../../shared-kernel';
import { VoiceConnectionFacade } from '../../../voice-connection';
import { ClientInstanceService } from '../../../../core/platform/client-instance.service';
import { UsersActions } from '../../../../store/users/users.actions';
import { isLocalVoiceOwner } from '../../domain/logic/client-voice-session.rules';
import { VoiceSessionFacade } from '../facades/voice-session.facade';
@Injectable({ providedIn: 'root' })
export class VoiceClientTakeoverService {
private readonly store = inject(Store);
private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly clientInstance = inject(ClientInstanceService);
releaseLocalVoiceForTakeover(currentUser: User | null): void {
if (!currentUser?.voiceState?.isConnected) {
return;
}
if (!isLocalVoiceOwner(currentUser.voiceState, this.clientInstance.getClientInstanceId())) {
return;
}
const previousVoiceState = currentUser.voiceState;
this.voiceConnection.stopVoiceHeartbeat();
this.voiceConnection.disableVoice();
this.store.dispatch(
UsersActions.updateVoiceState({
userId: currentUser.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
clientInstanceId: undefined
}
})
);
this.store.dispatch(
UsersActions.updateCameraState({
userId: currentUser.id,
cameraState: { isEnabled: false }
})
);
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: currentUser.oderId || currentUser.id,
displayName: currentUser.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: previousVoiceState.roomId,
serverId: previousVoiceState.serverId,
clientInstanceId: undefined
}
});
this.voiceSession.endSession();
}
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import type { VoiceState } from '../../../../shared-kernel';
import {
isLocalVoiceOwner,
isVoiceOnAnotherClient,
shouldTransmitVoice
} from './client-voice-session.rules';
describe('client-voice-session.rules', () => {
const localClientInstanceId = 'device-a';
it('treats the matching client instance as the local voice owner', () => {
const voiceState: VoiceState = {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
clientInstanceId: localClientInstanceId
};
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(true);
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(false);
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
});
it('treats a different client instance as passive voice', () => {
const voiceState: VoiceState = {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
clientInstanceId: 'device-b'
};
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(false);
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(true);
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(false);
});
it('allows transmission when disconnected', () => {
const voiceState: VoiceState = {
isConnected: false,
isMuted: false,
isDeafened: false,
isSpeaking: false
};
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
import type { VoiceState } from '../../../../shared-kernel';
export function isLocalVoiceOwner(
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
clientInstanceId: string
): boolean {
return !!voiceState?.isConnected
&& !!voiceState.clientInstanceId
&& voiceState.clientInstanceId === clientInstanceId;
}
export function isVoiceOnAnotherClient(
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
clientInstanceId: string
): boolean {
return !!voiceState?.isConnected
&& !!voiceState.clientInstanceId
&& voiceState.clientInstanceId !== clientInstanceId;
}
export function shouldTransmitVoice(
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
clientInstanceId: string
): boolean {
if (!voiceState?.isConnected) {
return true;
}
if (!voiceState.clientInstanceId) {
return true;
}
return voiceState.clientInstanceId === clientInstanceId;
}

View File

@@ -1,5 +1,7 @@
export * from './application/facades/voice-session.facade'; export * from './application/facades/voice-session.facade';
export * from './application/services/voice-client-takeover.service';
export * from './application/services/voice-workspace.service'; export * from './application/services/voice-workspace.service';
export * from './domain/logic/client-voice-session.rules';
export * from './domain/models/voice-session.model'; export * from './domain/models/voice-session.model';
export * from './infrastructure/util/voice-settings-storage.util'; export * from './infrastructure/util/voice-settings-storage.util';

View File

@@ -21,12 +21,15 @@
<input <input
#searchInput #searchInput
type="text" type="text"
appAutoFocus
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="submitSearch()"
[attr.aria-label]="'dashboard.searchAriaLabel' | translate" [attr.aria-label]="'dashboard.searchAriaLabel' | translate"
class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20" class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20"
[placeholder]="isMobile() ? ('dashboard.searchPlaceholderMobile' | translate) : ('dashboard.searchPlaceholderDesktop' | translate)" [placeholder]="isMobile() ? ('dashboard.searchPlaceholderMobile' | translate) : ('dashboard.searchPlaceholderDesktop' | translate)"
[ngModel]="searchQuery()" [ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)" (ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
/> />
<kbd <kbd
class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex" class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex"

View File

@@ -46,6 +46,11 @@ import { FriendButtonComponent } from '../../domains/direct-message/feature/frie
import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component'; import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component';
import { parseInviteQuery } from './invite-query.util'; import { parseInviteQuery } from './invite-query.util';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../shared/directives';
/** Maximum quick-search rows shown per group on the dashboard. */ /** Maximum quick-search rows shown per group on the dashboard. */
const QUICK_RESULT_LIMIT = 5; const QUICK_RESULT_LIMIT = 5;
@@ -72,6 +77,9 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
NgIcon, NgIcon,
FriendButtonComponent, FriendButtonComponent,
UserAvatarComponent, UserAvatarComponent,
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [

View File

@@ -17,7 +17,7 @@
[class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()" [class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()"
[class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()" [class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()"
[class.ring-border]="!speaking()" [class.ring-border]="!speaking()"
[class.opacity-55]="!connected()" [class.opacity-55]="!connected() || passive()"
> >
@if (user().avatarUrl) { @if (user().avatarUrl) {
<img <img

View File

@@ -18,6 +18,7 @@ export class PrivateCallParticipantCardComponent {
readonly speaking = input.required<boolean>(); readonly speaking = input.required<boolean>();
readonly issueLabel = input<string | null>(null); readonly issueLabel = input<string | null>(null);
readonly compact = input(false); readonly compact = input(false);
readonly passive = input(false);
avatarSize(): string { avatarSize(): string {
return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)'; return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)';

View File

@@ -148,6 +148,7 @@
[connected]="isParticipantConnected(user)" [connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)" [speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)" [issueLabel]="participantIssueLabel(user)"
[passive]="isPassiveCallParticipant(user)"
/> />
} }
</div> </div>
@@ -164,6 +165,7 @@
[connected]="isParticipantConnected(user)" [connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)" [speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)" [issueLabel]="participantIssueLabel(user)"
[passive]="isPassiveCallParticipant(user)"
[compact]="true" [compact]="true"
/> />
} }

View File

@@ -44,6 +44,11 @@ import {
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
import { ScreenShareQualityDialogComponent } from '../../shared'; import { ScreenShareQualityDialogComponent } from '../../shared';
import { ViewportService } from '../../core/platform'; import { ViewportService } from '../../core/platform';
import { RealtimeSessionFacade } from '../../core/realtime';
import {
isLocalVoiceOwner,
isVoiceOnAnotherClient
} from '../../domains/voice-session';
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile'; import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions'; import { UsersActions } from '../../store/users/users.actions';
@@ -85,6 +90,7 @@ export class PrivateCallComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly calls = inject(DirectCallService); private readonly calls = inject(DirectCallService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly voice = inject(VoiceConnectionFacade); private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService); private readonly playback = inject(VoicePlaybackService);
@@ -437,18 +443,38 @@ export class PrivateCallComponent {
isParticipantConnected(user: User): boolean { isParticipantConnected(user: User): boolean {
const session = this.session(); const session = this.session();
const userId = this.userKey(user); const userId = this.userKey(user);
const current = this.currentUser();
if (!session) { if (!session) {
return false; return false;
} }
return ( const inCallVoice = !!(
!!session.participants[userId]?.joined || user.voiceState?.isConnected
!!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId) && user.voiceState.roomId === session.callId
&& user.voiceState.serverId === session.callId
); );
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
if (isSelf && inCallVoice) {
return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId());
}
return !!session.participants[userId]?.joined || inCallVoice;
}
isPassiveCallParticipant(user: User): boolean {
const current = this.currentUser();
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
return isSelf && isVoiceOnAnotherClient(user.voiceState, this.realtime.getClientInstanceId());
} }
participantIssueLabel(user: User): string | null { participantIssueLabel(user: User): string | null {
if (this.isPassiveCallParticipant(user)) {
return this.i18n.instant('call.private.voiceOnOtherDevice');
}
return this.isParticipantConnected(user) ? null : this.i18n.instant('call.private.waiting'); return this.isParticipantConnected(user) ? null : this.i18n.instant('call.private.waiting');
} }

View File

@@ -98,6 +98,8 @@
<input <input
#renameInput #renameInput
type="text" type="text"
appAutoFocus
appSelectOnFocus
[value]="ch.name" [value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()" [class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''" [title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
@@ -173,7 +175,7 @@
(contextmenu)="openChannelContextMenu($event, ch)" (contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary]="isCurrentRoom(ch.id)" [class.bg-secondary]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()" [disabled]="!voiceEnabled()"
[title]="isCurrentRoom(ch.id) ? ('room.panel.openStreamWorkspace' | translate) : ('room.panel.joinVoiceChannel' | translate)" [title]="voiceChannelActionLabel(ch.id)"
data-channel-type="voice" data-channel-type="voice"
[attr.data-channel-name]="ch.name" [attr.data-channel-name]="ch.name"
> >
@@ -186,6 +188,8 @@
<input <input
#renameInput #renameInput
type="text" type="text"
appAutoFocus
appSelectOnFocus
[value]="ch.name" [value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()" [class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''" [title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
@@ -205,6 +209,10 @@
<span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary"> <span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
{{ isVoiceWorkspaceExpanded() ? ('room.panel.open' | translate) : ('room.panel.view' | translate) }} {{ isVoiceWorkspaceExpanded() ? ('room.panel.open' | translate) : ('room.panel.view' | translate) }}
</span> </span>
} @else if (isPassiveInVoiceRoom(ch.id)) {
<span class="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{{ 'room.panel.takeOverVoice' | translate }}
</span>
} @else if (voiceOccupancy(ch.id) > 0) { } @else if (voiceOccupancy(ch.id) > 0) {
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span> <span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
} }
@@ -220,6 +228,7 @@
appThemeNode="roomVoiceUserItem" appThemeNode="roomVoiceUserItem"
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50" class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
[class.cursor-pointer]="canDragVoiceUser(u)" [class.cursor-pointer]="canDragVoiceUser(u)"
[class.opacity-50]="isPassiveVoiceUser(u)"
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)" [class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
[draggable]="canDragVoiceUser(u)" [draggable]="canDragVoiceUser(u)"
(dragstart)="onVoiceUserDragStart($event, u)" (dragstart)="onVoiceUserDragStart($event, u)"
@@ -368,6 +377,7 @@
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">{{ 'room.panel.you' | translate }}</h4> <h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">{{ 'room.panel.you' | translate }}</h4>
<div <div
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer" class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
[class.opacity-50]="isPassiveVoiceClient()"
role="button" role="button"
tabindex="0" tabindex="0"
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()" (click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
@@ -413,7 +423,11 @@
name="lucideMic" name="lucideMic"
class="w-2.5 h-2.5" class="w-2.5 h-2.5"
/> />
{{ 'room.panel.inVoice' | translate }} @if (isPassiveVoiceClient()) {
{{ 'room.panel.voiceOnOtherDevice' | translate }}
} @else {
{{ 'room.panel.inVoice' | translate }}
}
</p> </p>
} }
@if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) { @if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) {
@@ -763,7 +777,6 @@
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-destructive]="!!channelNameError()" [class.border-destructive]="!!channelNameError()"
(ngModelChange)="clearChannelNameError()" (ngModelChange)="clearChannelNameError()"
(keydown.enter)="confirmCreateChannel()"
/> />
@if (channelNameError()) { @if (channelNameError()) {
<p class="mt-2 text-sm text-destructive">{{ channelNameError()! | translate }}</p> <p class="mt-2 text-sm text-destructive">{{ channelNameError()! | translate }}</p>

View File

@@ -52,7 +52,12 @@ import {
VoiceConnectionFacade, VoiceConnectionFacade,
VoiceConnectivityHealthService VoiceConnectivityHealthService
} from '../../../domains/voice-connection'; } from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session'; import {
VoiceSessionFacade,
VoiceWorkspaceService,
isLocalVoiceOwner,
isVoiceOnAnotherClient
} from '../../../domains/voice-session';
import { DirectMessageService } from '../../../domains/direct-message'; import { DirectMessageService } from '../../../domains/direct-message';
import { DirectCallService } from '../../../domains/direct-call'; import { DirectCallService } from '../../../domains/direct-call';
import { VoicePlaybackService } from '../../../domains/voice-connection'; import { VoicePlaybackService } from '../../../domains/voice-connection';
@@ -88,6 +93,7 @@ import {
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { visibilityAwareInterval$ } from '../../../shared/rxjs'; import { visibilityAwareInterval$ } from '../../../shared/rxjs';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../shared/directives';
type PanelMode = 'channels' | 'users'; type PanelMode = 'channels' | 'users';
@@ -109,6 +115,8 @@ const SKELETON_REVEAL_DELAY_MS = 180;
ThemeNodeDirective, ThemeNodeDirective,
SkeletonComponent, SkeletonComponent,
SkeletonListComponent, SkeletonListComponent,
AutoFocusDirective,
SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [
@@ -689,7 +697,13 @@ export class RoomsSidePanelComponent implements OnDestroy {
} }
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean { private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) { if (
!room
|| !current?.voiceState?.isConnected
|| current.voiceState.roomId !== roomId
|| current.voiceState.serverId !== room.id
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
) {
return false; return false;
} }
@@ -697,6 +711,47 @@ export class RoomsSidePanelComponent implements OnDestroy {
return true; return true;
} }
isPassiveInVoiceRoom(roomId: string): boolean {
const current = this.currentUser();
const room = this.currentRoom();
return !!current?.voiceState?.isConnected
&& current.voiceState.roomId === roomId
&& current.voiceState.serverId === room?.id
&& isVoiceOnAnotherClient(current.voiceState, this.realtime.getClientInstanceId());
}
isPassiveVoiceClient(): boolean {
const current = this.currentUser();
return isVoiceOnAnotherClient(current?.voiceState, this.realtime.getClientInstanceId());
}
isPassiveVoiceUser(user: User | null): boolean {
const current = this.currentUser();
if (!user || !current) {
return false;
}
return (user.id === current.id || user.oderId === current.oderId)
&& this.isPassiveVoiceClient();
}
voiceChannelActionLabel(roomId: string): string {
if (this.isCurrentRoom(roomId)) {
return this.isVoiceWorkspaceExpanded()
? this.appI18n.instant('room.panel.open')
: this.appI18n.instant('room.panel.view');
}
if (this.isPassiveInVoiceRoom(roomId)) {
return this.appI18n.instant('room.panel.takeOverVoice');
}
return this.appI18n.instant('room.panel.joinVoiceChannel');
}
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean { private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId); return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
} }
@@ -737,9 +792,19 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.directCalls.leaveCurrentJoinedCall(); this.directCalls.leaveCurrentJoinedCall();
this.prepareVoiceJoin(room, current ?? null); this.prepareVoiceJoin(room, current ?? null);
this.enableVoiceForJoin(room, current ?? null, roomId) const startJoin = () => {
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null)) this.enableVoiceForJoin(room, current ?? null, roomId)
.catch((error) => this.handleVoiceJoinFailure(error)); .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch((error) => this.handleVoiceJoinFailure(error));
};
if (this.isPassiveInVoiceRoom(roomId) || this.isPassiveVoiceClient()) {
this.realtime.requestVoiceClientTakeover();
window.setTimeout(startJoin, 300);
return;
}
startJoin();
} }
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void { private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
@@ -786,7 +851,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: current.voiceState?.isMuted ?? false, isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false, isDeafened: current.voiceState?.isDeafened ?? false,
roomId, roomId,
serverId: room.id serverId: room.id,
clientInstanceId: this.realtime.getClientInstanceId()
} }
}) })
); );
@@ -797,6 +863,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
} }
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void { private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
const clientInstanceId = this.realtime.getClientInstanceId();
this.voiceConnection.broadcastMessage({ this.voiceConnection.broadcastMessage({
type: 'voice-state', type: 'voice-state',
oderId: current?.oderId || current?.id, oderId: current?.oderId || current?.id,
@@ -806,7 +874,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: current?.voiceState?.isMuted ?? false, isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false, isDeafened: current?.voiceState?.isDeafened ?? false,
roomId, roomId,
serverId: room.id serverId: room.id,
clientInstanceId
} }
}); });
} }
@@ -851,7 +920,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
roomId: undefined, roomId: undefined,
serverId: undefined serverId: undefined,
clientInstanceId: undefined
} }
}) })
); );
@@ -873,7 +943,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
roomId: previousVoiceState?.roomId, roomId: previousVoiceState?.roomId,
serverId: previousVoiceState?.serverId serverId: previousVoiceState?.serverId,
clientInstanceId: undefined
} }
}); });
@@ -1110,7 +1181,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
const me = this.currentUser(); const me = this.currentUser();
const room = this.currentRoom(); const room = this.currentRoom();
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id); return !!(
me?.voiceState?.isConnected
&& me.voiceState.roomId === roomId
&& me.voiceState.serverId === room?.id
&& isLocalVoiceOwner(me.voiceState, this.realtime.getClientInstanceId())
);
} }
voiceEnabled(): boolean { voiceEnabled(): boolean {

View File

@@ -27,6 +27,7 @@ import {
} from 'rxjs'; } from 'rxjs';
import { Room, User } from '../../../shared-kernel'; import { Room, User } from '../../../shared-kernel';
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component'; import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
import { VoiceSessionFacade } from '../../../domains/voice-session'; import { VoiceSessionFacade } from '../../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
@@ -276,7 +277,9 @@ export class ServersRailComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
return; return;
} }

View File

@@ -154,11 +154,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
type="text" type="text"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addIgnoredProcess()"
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="'settings.general.gameDetection.processPlaceholder' | translate" [placeholder]="'settings.general.gameDetection.processPlaceholder' | translate"
[value]="ignoredProcessDraft()" [value]="ignoredProcessDraft()"
(input)="onIgnoredProcessDraftChange($event)" (input)="onIgnoredProcessDraftChange($event)"
(keydown.enter)="addIgnoredProcess()"
[attr.aria-label]="'settings.general.gameDetection.processAria' | translate" [attr.aria-label]="'settings.general.gameDetection.processAria' | translate"
/> />
<button <button

View File

@@ -14,11 +14,21 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
import { PlatformService } from '../../../../core/platform'; import { PlatformService } from '../../../../core/platform';
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service'; import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({ @Component({
selector: 'app-general-settings', selector: 'app-general-settings',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS], imports: [
CommonModule,
NgIcon,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucidePower lucidePower

View File

@@ -113,6 +113,10 @@
</select> </select>
<input <input
type="text" type="text"
appAutoFocus
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addEntry()"
[(ngModel)]="newUrl" [(ngModel)]="newUrl"
data-testid="ice-url-input" data-testid="ice-url-input"
[placeholder]="(newType === 'stun' ? 'settings.network.ice.stunPlaceholder' : 'settings.network.ice.turnPlaceholder') | translate" [placeholder]="(newType === 'stun' ? 'settings.network.ice.stunPlaceholder' : 'settings.network.ice.turnPlaceholder') | translate"
@@ -123,6 +127,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
appSelectOnFocus
[(ngModel)]="newUsername" [(ngModel)]="newUsername"
data-testid="ice-username-input" data-testid="ice-username-input"
[placeholder]="'settings.network.ice.username' | translate" [placeholder]="'settings.network.ice.username' | translate"
@@ -130,6 +135,8 @@
/> />
<input <input
type="password" type="password"
appSubmitOnEnter
(submitOnEnter)="addEntry()"
[(ngModel)]="newCredential" [(ngModel)]="newCredential"
data-testid="ice-credential-input" data-testid="ice-credential-input"
[placeholder]="'settings.network.ice.credential' | translate" [placeholder]="'settings.network.ice.credential' | translate"

View File

@@ -18,6 +18,11 @@ import {
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service'; import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({ @Component({
selector: 'app-ice-server-settings', selector: 'app-ice-server-settings',
@@ -29,6 +34,9 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
CommonModule, CommonModule,
FormsModule, FormsModule,
NgIcon, NgIcon,
AutoFocusDirective,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [

View File

@@ -126,12 +126,16 @@
<div class="flex-1 space-y-1.5"> <div class="flex-1 space-y-1.5">
<input <input
type="text" type="text"
appSelectOnFocus
[(ngModel)]="newServerName" [(ngModel)]="newServerName"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholderShort' | translate" [placeholder]="'settings.network.serverEndpoints.serverNamePlaceholderShort' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/> />
<input <input
type="url" type="url"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addServer()"
[(ngModel)]="newServerUrl" [(ngModel)]="newServerUrl"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate" [placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"

View File

@@ -22,6 +22,10 @@ import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants'; import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component'; import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({ @Component({
selector: 'app-network-settings', selector: 'app-network-settings',
@@ -31,6 +35,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
FormsModule, FormsModule,
NgIcon, NgIcon,
IceServerSettingsComponent, IceServerSettingsComponent,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [

View File

@@ -110,6 +110,9 @@
}}</span> }}</span>
<input <input
type="text" type="text"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="saveRoleDetails()"
[ngModel]="roleName" [ngModel]="roleName"
(ngModelChange)="roleName = $event" (ngModelChange)="roleName = $event"
[disabled]="!canEditSelectedRoleMetadata()" [disabled]="!canEditSelectedRoleMetadata()"

View File

@@ -39,6 +39,10 @@ import {
withUpdatedRole withUpdatedRole
} from '../../../../domains/access-control'; } from '../../../../domains/access-control';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
function upsertRoleChannelOverride( function upsertRoleChannelOverride(
overrides: readonly ChannelPermissionOverride[] | undefined, overrides: readonly ChannelPermissionOverride[] | undefined,
@@ -75,6 +79,8 @@ function upsertRoleChannelOverride(
CommonModule, CommonModule,
FormsModule, FormsModule,
NgIcon, NgIcon,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [

View File

@@ -82,6 +82,9 @@
[(ngModel)]="roomName" [(ngModel)]="roomName"
[readOnly]="!isAdmin()" [readOnly]="!isAdmin()"
id="room-name" id="room-name"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="saveServerSettings()"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[class.opacity-60]="!isAdmin()" [class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()" [class.cursor-not-allowed]="!isAdmin()"

View File

@@ -26,6 +26,10 @@ import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service'; import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../../../shared/directives';
@Component({ @Component({
selector: 'app-server-settings', selector: 'app-server-settings',
@@ -35,6 +39,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
FormsModule, FormsModule,
NgIcon, NgIcon,
ConfirmDialogComponent, ConfirmDialogComponent,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [

View File

@@ -163,12 +163,16 @@
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<input <input
type="text" type="text"
appSelectOnFocus
[(ngModel)]="newServerName" [(ngModel)]="newServerName"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholder' | translate" [placeholder]="'settings.network.serverEndpoints.serverNamePlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/> />
<input <input
type="url" type="url"
appSelectOnFocus
appSubmitOnEnter
(submitOnEnter)="addServer()"
[(ngModel)]="newServerUrl" [(ngModel)]="newServerUrl"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate" [placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"

View File

@@ -29,6 +29,10 @@ import { VoiceConnectionFacade } from '../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants'; import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
import {
SelectOnFocusDirective,
SubmitOnEnterDirective
} from '../../shared/directives';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@@ -37,6 +41,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
CommonModule, CommonModule,
FormsModule, FormsModule,
NgIcon, NgIcon,
SelectOnFocusDirective,
SubmitOnEnterDirective,
...APP_TRANSLATE_IMPORTS ...APP_TRANSLATE_IMPORTS
], ],
viewProviders: [ viewProviders: [

View File

@@ -40,6 +40,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../../domains/server-directory'; import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { PlatformService } from '../../../core/platform'; import { PlatformService } from '../../../core/platform';
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage'; import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared'; import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
import { Room, type PluginRequirementSummary } from '../../../shared-kernel'; import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
@@ -211,7 +212,9 @@ export class TitleBarComponent {
/** Navigate to the login page. */ /** Navigate to the login page. */
goLogin() { goLogin() {
this.router.navigate(['/login']); this.router.navigate(['/login'], {
queryParams: buildLoginReturnQueryParams(this.router.url)
});
} }
openPluginStore(): void { openPluginStore(): void {

View File

@@ -0,0 +1,46 @@
import {
describe,
it,
expect
} from 'vitest';
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
describe('aggregateComponentCountsByDomain', () => {
it('groups component counts under inferred domains', () => {
expect(aggregateComponentCountsByDomain({
VoiceChannelPanelComponent: 2,
DmChatComponent: 1,
App: 1
})).toEqual({
'voice-connection': 2,
'direct-message': 1,
core: 1
});
});
});
describe('detectSuspectedComponentLeaks', () => {
it('flags components that exceed the expected live count', () => {
expect(detectSuspectedComponentLeaks({
DmChatComponent: 3,
App: 1
}, {
DmChatComponent: 0,
App: 1
})).toEqual([
{
name: 'DmChatComponent',
count: 3,
expected: 0
}
]);
});
it('returns an empty list when counts are within expectations', () => {
expect(detectSuspectedComponentLeaks({
App: 1
}, {
App: 1
})).toEqual([]);
});
});

View File

@@ -0,0 +1,38 @@
import { mapComponentNameToDomain } from './domain-mapping.rules';
export interface SuspectedComponentLeak {
name: string;
count: number;
expected: number;
}
export function aggregateComponentCountsByDomain(
componentCounts: Record<string, number>
): Record<string, number> {
const domains: Record<string, number> = {};
for (const [componentName, count] of Object.entries(componentCounts)) {
const domain = mapComponentNameToDomain(componentName);
domains[domain] = (domains[domain] ?? 0) + count;
}
return domains;
}
export function detectSuspectedComponentLeaks(
componentCounts: Record<string, number>,
expectedCounts: Record<string, number>
): SuspectedComponentLeak[] {
const leaks: SuspectedComponentLeak[] = [];
for (const [name, count] of Object.entries(componentCounts)) {
const expected = expectedCounts[name] ?? 0;
if (count > expected) {
leaks.push({ name, count, expected });
}
}
return leaks.sort((left, right) => right.count - left.count);
}

View File

@@ -0,0 +1,69 @@
import { ApplicationRef } from '@angular/core';
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
const DEFAULT_DOM_SCAN_BUDGET = 400;
interface NgDebugApi {
getComponent?: (element: Element) => { constructor: { name: string } } | null;
}
export interface ComponentTreeScanResult {
components: Record<string, number>;
domains: Record<string, number>;
suspectedLeaks: ReturnType<typeof detectSuspectedComponentLeaks>;
scannedNodes: number;
scanMode: 'application-ref' | 'ng-global';
}
export function scanComponentTree(
appRef: ApplicationRef,
expectedCounts: Record<string, number>,
options: { domScanBudget?: number } = {}
): ComponentTreeScanResult {
const domScanBudget = options.domScanBudget ?? DEFAULT_DOM_SCAN_BUDGET;
const ngApi = (globalThis as { ng?: NgDebugApi }).ng;
const components: Record<string, number> = {};
let scannedNodes = 0;
let scanMode: ComponentTreeScanResult['scanMode'] = 'application-ref';
if (ngApi?.getComponent && typeof document !== 'undefined') {
scanMode = 'ng-global';
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode() && scannedNodes < domScanBudget) {
scannedNodes += 1;
try {
const component = ngApi.getComponent?.(walker.currentNode as Element) as {
constructor: { name: string };
} | null;
if (!component) {
continue;
}
const name = component.constructor.name;
components[name] = (components[name] ?? 0) + 1;
} catch {
// Ignore elements that are not component hosts.
}
}
} else {
for (const componentRef of appRef.components) {
const name = componentRef.componentType.name;
components[name] = (components[name] ?? 0) + 1;
scannedNodes += 1;
}
}
return {
components,
domains: aggregateComponentCountsByDomain(components),
suspectedLeaks: detectSuspectedComponentLeaks(components, expectedCounts),
scannedNodes,
scanMode
};
}

View File

@@ -0,0 +1,96 @@
import {
EnvironmentInjector,
inject,
runInInjectionContext
} from '@angular/core';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
const SAMPLE_INTERVAL_MS = 10_000;
let started = false;
let sampleTimer: ReturnType<typeof setInterval> | null = null;
export async function bootstrapPerfDiagnostics(
api: ElectronApi,
injector: EnvironmentInjector
): Promise<void> {
const reportSample = api.reportPerfDiagSample;
if (started || !api.isPerfDiagEnabled || !reportSample) {
return;
}
let enabled = false;
try {
enabled = await api.isPerfDiagEnabled();
} catch {
return;
}
if (!enabled) {
return;
}
started = true;
const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry)
};
const runSample = (): void => {
void runInInjectionContext(injector, async () => {
try {
const collector = inject(PerfDiagnosticsCollector);
await publishRendererDiagnosticsSample(reporter, collector);
} catch {
stopPerfDiagnosticsSampling();
}
});
};
scheduleSample(runSample);
sampleTimer = setInterval(() => scheduleSample(runSample), SAMPLE_INTERVAL_MS);
window.addEventListener('error', () => {
void reporter.report({
collectedAt: Date.now(),
source: 'renderer',
type: 'crash',
payload: { scope: 'window-error' }
});
});
window.addEventListener('unhandledrejection', () => {
void reporter.report({
collectedAt: Date.now(),
source: 'renderer',
type: 'crash',
payload: { scope: 'unhandled-rejection' }
});
});
}
function scheduleSample(runSample: () => void): void {
const idle = (globalThis as {
requestIdleCallback?: (handler: () => void, options?: { timeout: number }) => number;
}).requestIdleCallback;
if (idle) {
idle(() => runSample(), { timeout: SAMPLE_INTERVAL_MS });
return;
}
setTimeout(runSample, 0);
}
function stopPerfDiagnosticsSampling(): void {
if (sampleTimer) {
clearInterval(sampleTimer);
sampleTimer = null;
}
started = false;
}

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