feat: signal server tag
This commit is contained in:
@@ -10,6 +10,7 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
||||
|
||||
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
||||
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
||||
|
||||
The product client already documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
|
||||
|
||||
|
||||
@@ -25,6 +25,20 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## Lessons
|
||||
|
||||
### Use the upgrade transaction during IndexedDB schema migrations [persistence] [browser]
|
||||
|
||||
- **Trigger:** bumping `BROWSER_DATABASE_VERSION` and opening existing stores via `database.transaction(...)` inside `onupgradeneeded`.
|
||||
- **Rule:** during `onupgradeneeded`, reuse `event.transaction.objectStore(name)` for existing stores and only call `database.createObjectStore` for missing ones — never start a second transaction while the version-change transaction is active.
|
||||
- **Why:** nested transactions abort the upgrade, `authenticateUser` storage prep fails, and login/register navigates before `setCurrentUser` so DM routes throw "Cannot use direct messages without a current user."
|
||||
- **Example:** `ensureObjectStoreDuringUpgrade(database, upgradeTransaction, 'messages')` in `browser-database-schema.ts`.
|
||||
|
||||
### Wait for authenticateUser storage prep before post-login navigation [authentication] [browser]
|
||||
|
||||
- **Trigger:** dispatching `UsersActions.authenticateUser` from login/register and immediately calling `router.navigate(...)`.
|
||||
- **Rule:** wait for `setCurrentUser` or `loadCurrentUserFailure` (e.g. `waitForAuthenticationOutcome(actions$)`) before navigating to `returnUrl` or `/dashboard`.
|
||||
- **Why:** `authenticateUser$` prepares per-user IndexedDB asynchronously; early navigation renders DM/shell routes before the current user exists in the store.
|
||||
- **Example:** `await firstValueFrom(waitForAuthenticationOutcome(this.actions$))` in `register.component.ts` and `login.component.ts`.
|
||||
|
||||
### Use dense arrays for chunked transfer buffers [custom-emoji] [webrtc]
|
||||
|
||||
- **Trigger:** chunked P2P asset assembly marks a transfer complete after the first chunk because `array.some()` skips sparse holes created by `new Array(total)`.
|
||||
|
||||
22
agents-docs/features/signal-server-tag.md
Normal file
22
agents-docs/features/signal-server-tag.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Signal Server Tag
|
||||
|
||||
Users registered on a signal server can show that server's display tag on their profile card (opened by clicking their name or avatar).
|
||||
|
||||
## Server configuration
|
||||
|
||||
`server/data/variables.json` accepts an optional `serverTag` string. When omitted, the server falls back to its public URL built from `serverProtocol`, `serverHost`, and `serverPort`.
|
||||
|
||||
## Health API
|
||||
|
||||
`GET /api/health` includes `serverTag` so clients can cache the display label per configured endpoint.
|
||||
|
||||
## WebSocket presence
|
||||
|
||||
The client sends `homeSignalServerUrl` in `identify` messages. The signaling server echoes that value in `server_users` and `user_joined` payloads so other clients can resolve the correct tag.
|
||||
|
||||
## Client behavior
|
||||
|
||||
- Login and registration store `homeSignalServerUrl` on the current user.
|
||||
- Profile cards show the resolved tag beside the username in muted text.
|
||||
- Configured labels render as `#tag`; URL fallbacks render as a globe icon with the URL in a tooltip.
|
||||
- Tag resolution prefers the endpoint's cached `serverTag` from health checks, then falls back to the stored home URL.
|
||||
@@ -24,7 +24,8 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
|
||||
isAdmin: user.isAdmin ? 1 : 0,
|
||||
isRoomOwner: user.isRoomOwner ? 1 : 0,
|
||||
voiceState: user.voiceState != null ? JSON.stringify(user.voiceState) : null,
|
||||
screenShareState: user.screenShareState != null ? JSON.stringify(user.screenShareState) : null
|
||||
screenShareState: user.screenShareState != null ? JSON.stringify(user.screenShareState) : null,
|
||||
homeSignalServerUrl: user.homeSignalServerUrl ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
|
||||
@@ -61,7 +61,8 @@ export function rowToUser(row: UserEntity) {
|
||||
isAdmin: !!row.isAdmin,
|
||||
isRoomOwner: !!row.isRoomOwner,
|
||||
voiceState: row.voiceState ? JSON.parse(row.voiceState) : undefined,
|
||||
screenShareState: row.screenShareState ? JSON.parse(row.screenShareState) : undefined
|
||||
screenShareState: row.screenShareState ? JSON.parse(row.screenShareState) : undefined,
|
||||
homeSignalServerUrl: row.homeSignalServerUrl ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface UserPayload {
|
||||
isRoomOwner?: boolean;
|
||||
voiceState?: unknown;
|
||||
screenShareState?: unknown;
|
||||
homeSignalServerUrl?: string;
|
||||
}
|
||||
|
||||
export interface RoomPayload {
|
||||
|
||||
@@ -62,4 +62,7 @@ export class UserEntity {
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
screenShareState!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
homeSignalServerUrl!: string | null;
|
||||
}
|
||||
|
||||
13
electron/migrations/1000000000012-AddHomeSignalServerUrl.ts
Normal file
13
electron/migrations/1000000000012-AddHomeSignalServerUrl.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddHomeSignalServerUrl1000000000012 implements MigrationInterface {
|
||||
name = 'AddHomeSignalServerUrl1000000000012';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "homeSignalServerUrl" TEXT`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// SQLite column removal requires table rebuilds. Keep rollback no-op.
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
||||
- The server loads the repository-root `.env` file on startup.
|
||||
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||
- `DB_PATH` can override the SQLite database file location.
|
||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, `serverTag`, and `linkPreview`. When `serverTag` is empty, `GET /api/health` falls back to the server's public URL.
|
||||
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
|
||||
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
||||
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
||||
|
||||
33
server/src/config/signal-server-tag.ts
Normal file
33
server/src/config/signal-server-tag.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ServerHttpProtocol } from './variables';
|
||||
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.startsWith('[') || !host.includes(':')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
return `[${host}]`;
|
||||
}
|
||||
|
||||
function getDisplayHost(serverHost: string | undefined): string {
|
||||
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
return serverHost;
|
||||
}
|
||||
|
||||
export function buildSignalServerPublicUrl(
|
||||
serverProtocol: ServerHttpProtocol,
|
||||
serverHost: string | undefined,
|
||||
serverPort: number
|
||||
): string {
|
||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||
|
||||
return `${serverProtocol}://${displayHost}:${serverPort}`;
|
||||
}
|
||||
|
||||
export function resolveSignalServerTag(configuredTag: string | undefined, publicUrl: string): string {
|
||||
const normalizedTag = configuredTag?.trim();
|
||||
|
||||
return normalizedTag || publicUrl;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveRuntimePath } from '../runtime-paths';
|
||||
import { buildSignalServerPublicUrl, resolveSignalServerTag } from './signal-server-tag';
|
||||
|
||||
export type ServerHttpProtocol = 'http' | 'https';
|
||||
|
||||
@@ -21,6 +22,7 @@ export interface ServerVariablesConfig {
|
||||
serverPort: number;
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
serverTag: string;
|
||||
linkPreview: LinkPreviewConfig;
|
||||
openApiDocs: OpenApiDocsConfig;
|
||||
}
|
||||
@@ -49,6 +51,10 @@ function normalizeServerHost(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerTag(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerProtocol(
|
||||
value: unknown,
|
||||
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
|
||||
@@ -162,6 +168,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||
serverTag: normalizeServerTag(remainingParsed.serverTag),
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
||||
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
||||
};
|
||||
@@ -178,6 +185,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost,
|
||||
serverTag: normalized.serverTag,
|
||||
linkPreview: normalized.linkPreview,
|
||||
openApiDocs: normalized.openApiDocs
|
||||
};
|
||||
@@ -229,6 +237,18 @@ export function getServerHost(): string | undefined {
|
||||
return serverHost || undefined;
|
||||
}
|
||||
|
||||
export function getSignalServerPublicUrl(): string {
|
||||
const config = getVariablesConfig();
|
||||
|
||||
return buildSignalServerPublicUrl(config.serverProtocol, config.serverHost, config.serverPort);
|
||||
}
|
||||
|
||||
export function getServerTag(): string {
|
||||
const config = getVariablesConfig();
|
||||
|
||||
return resolveSignalServerTag(config.serverTag, getSignalServerPublicUrl());
|
||||
}
|
||||
|
||||
export function isHttpsServerEnabled(): boolean {
|
||||
return getServerProtocol() === 'https';
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getAllPublicServers } from '../cqrs';
|
||||
import { getReleaseManifestUrl } from '../config/variables';
|
||||
import { getReleaseManifestUrl, getServerTag } from '../config/variables';
|
||||
import { SERVER_BUILD_VERSION } from '../generated/build-version';
|
||||
import { connectedUsers } from '../websocket/state';
|
||||
|
||||
@@ -27,7 +27,8 @@ router.get('/health', async (_req, res) => {
|
||||
connectedUsers: connectedUsers.size,
|
||||
serverInstanceId: SERVER_INSTANCE_ID,
|
||||
serverVersion: getServerProjectVersion(),
|
||||
releaseManifestUrl: getReleaseManifestUrl()
|
||||
releaseManifestUrl: getReleaseManifestUrl(),
|
||||
serverTag: getServerTag()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,11 +68,19 @@ describe('server websocket handler - status_update', () => {
|
||||
});
|
||||
|
||||
it('treats signaling keepalive messages as connection liveness', async () => {
|
||||
createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
|
||||
const user = createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'keepalive' });
|
||||
|
||||
expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1);
|
||||
const sentMessages = (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
|
||||
const ack = JSON.parse(sentMessages[0]) as { type: string; serverTime: number };
|
||||
|
||||
expect(ack.type).toBe('keepalive_ack');
|
||||
expect(ack.serverTime).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('updates user status on valid status_update message', async () => {
|
||||
@@ -276,6 +284,34 @@ describe('server websocket handler - profile metadata in presence messages', ()
|
||||
expect(aliceInList?.profileUpdatedAt).toBe(123);
|
||||
});
|
||||
|
||||
it('includes homeSignalServerUrl in server_users responses', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'user-1');
|
||||
const bob = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
alice.serverIds.add('server-1');
|
||||
bob.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'identify',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
homeSignalServerUrl: 'http://signal.example.com:3001/'
|
||||
});
|
||||
|
||||
getSentMessagesStore(bob).sentMessages.length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-2', {
|
||||
type: 'view_server',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
const messages = getSentMessagesStore(bob).sentMessages.map((messageText: string) => JSON.parse(messageText));
|
||||
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
|
||||
const aliceInList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
|
||||
|
||||
expect(aliceInList?.homeSignalServerUrl).toBe('http://signal.example.com:3001');
|
||||
});
|
||||
|
||||
it('includes description and profileUpdatedAt in user_joined broadcasts', async () => {
|
||||
const bob = createConnectedUser('conn-2', 'user-2');
|
||||
|
||||
|
||||
@@ -39,6 +39,34 @@ function normalizeProfileUpdatedAt(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeHomeSignalServerUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim().replace(/\/+$/, '');
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function buildPresenceUserPayload(user: ConnectedUser): {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
status: 'online' | 'away' | 'busy' | 'offline';
|
||||
} {
|
||||
return {
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
homeSignalServerUrl: user.homeSignalServerUrl,
|
||||
status: user.status ?? 'online'
|
||||
};
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
@@ -82,13 +110,7 @@ function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => ({
|
||||
oderId: cu.oderId,
|
||||
displayName: normalizeDisplayName(cu.displayName),
|
||||
description: cu.description,
|
||||
profileUpdatedAt: cu.profileUpdatedAt,
|
||||
status: cu.status ?? 'online'
|
||||
}));
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => buildPresenceUserPayload(cu));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
@@ -115,6 +137,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||
const previousDescription = user.description;
|
||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
@@ -127,11 +150,20 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'homeSignalServerUrl')) {
|
||||
user.homeSignalServerUrl = normalizeHomeSignalServerUrl(message['homeSignalServerUrl']);
|
||||
}
|
||||
|
||||
user.connectionScope = newScope;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
|
||||
if (user.displayName === previousDisplayName && user.description === previousDescription && user.profileUpdatedAt === previousProfileUpdatedAt) {
|
||||
if (
|
||||
user.displayName === previousDisplayName
|
||||
&& user.description === previousDescription
|
||||
&& user.profileUpdatedAt === previousProfileUpdatedAt
|
||||
&& user.homeSignalServerUrl === previousHomeSignalServerUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,11 +172,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
serverId,
|
||||
{
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId
|
||||
},
|
||||
user.oderId
|
||||
@@ -191,11 +219,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
sid,
|
||||
{
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
status: user.status ?? 'online',
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId: sid
|
||||
},
|
||||
user.oderId
|
||||
@@ -460,6 +484,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
|
||||
switch (message.type) {
|
||||
case 'keepalive':
|
||||
user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() }));
|
||||
break;
|
||||
|
||||
case 'identify':
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ConnectedUser {
|
||||
* URLs routing to the same server coexist without an eviction loop.
|
||||
*/
|
||||
connectionScope?: string;
|
||||
/** Public signal-server URL the user registered on. */
|
||||
homeSignalServerUrl?: string;
|
||||
/** User availability status (online, away, busy, offline). */
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Latest server icon timestamp this connection can provide over P2P. */
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
[ngStyle]="isMobile() ? null : appWorkspaceShellStyles()"
|
||||
>
|
||||
@if (!isMobile()) {
|
||||
<app-title-bar class="block shrink-0" />
|
||||
<app-title-bar class="block shrink-0" />
|
||||
}
|
||||
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
@@ -94,16 +94,14 @@
|
||||
</div>
|
||||
|
||||
@if (isMobile() && directCalls.mobileOverlaySession(); as call) {
|
||||
<div class="absolute inset-0 z-[70]">
|
||||
<app-private-call
|
||||
class="block h-full w-full"
|
||||
[callIdInput]="call.callId"
|
||||
[overlayMode]="true"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isThemeStudioFullscreen()) {
|
||||
<div class="absolute inset-0 z-[70]">
|
||||
<app-private-call
|
||||
class="block h-full w-full"
|
||||
[callIdInput]="call.callId"
|
||||
[overlayMode]="true"
|
||||
/>
|
||||
</div>
|
||||
} @if (isThemeStudioFullscreen()) {
|
||||
<div
|
||||
#themeStudioControlsRef
|
||||
class="pointer-events-none absolute z-[80]"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { waitForAuthenticationOutcome } from './auth-navigation.rules';
|
||||
|
||||
describe('waitForAuthenticationOutcome', () => {
|
||||
it('resolves when authentication storage preparation succeeds', async () => {
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
oderId: 'user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
status: 'online' as const,
|
||||
role: 'member' as const,
|
||||
joinedAt: 1
|
||||
};
|
||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(of(
|
||||
UsersActions.setCurrentUser({ user })
|
||||
)));
|
||||
|
||||
expect(outcome).toEqual({ kind: 'success', user });
|
||||
});
|
||||
|
||||
it('resolves with a failure when authentication storage preparation fails', async () => {
|
||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(of(
|
||||
UsersActions.loadCurrentUserFailure({ error: 'Failed to prepare local user state.' })
|
||||
)));
|
||||
|
||||
expect(outcome).toEqual({
|
||||
kind: 'failure',
|
||||
error: 'Failed to prepare local user state.'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
filter,
|
||||
map,
|
||||
Observable,
|
||||
take
|
||||
} from 'rxjs';
|
||||
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
export type AuthenticationOutcome =
|
||||
| { kind: 'success'; user: User }
|
||||
| { kind: 'failure'; error: string };
|
||||
|
||||
export function waitForAuthenticationOutcome(
|
||||
actions$: Observable<{ type: string; user?: User; error?: string }>
|
||||
): Observable<AuthenticationOutcome> {
|
||||
return actions$.pipe(
|
||||
filter((action) =>
|
||||
action.type === UsersActions.setCurrentUser.type
|
||||
|| action.type === UsersActions.loadCurrentUserFailure.type
|
||||
),
|
||||
take(1),
|
||||
map((action) => {
|
||||
if (action.type === UsersActions.loadCurrentUserFailure.type) {
|
||||
return {
|
||||
kind: 'failure' as const,
|
||||
error: action.error || 'Authentication failed'
|
||||
};
|
||||
}
|
||||
|
||||
if (!action.user) {
|
||||
return {
|
||||
kind: 'failure' as const,
|
||||
error: 'Authentication failed'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'success' as const,
|
||||
user: action.user
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,15 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Actions } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
|
||||
@@ -40,6 +43,7 @@ export class LoginComponent {
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private auth = inject(AuthenticationService);
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
@@ -55,10 +59,12 @@ export class LoginComponent {
|
||||
this.auth.login({ username: this.username.trim(),
|
||||
password: this.password,
|
||||
serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
next: async (resp) => {
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
||||
?? this.serversSvc.activeServer()?.url;
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
@@ -66,19 +72,27 @@ export class LoginComponent {
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now()
|
||||
joinedAt: Date.now(),
|
||||
homeSignalServerUrl
|
||||
};
|
||||
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
this.router.navigateByUrl(returnUrl);
|
||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
||||
|
||||
if (outcome.kind === 'failure') {
|
||||
this.error.set(outcome.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/dashboard']);
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
|
||||
@@ -7,12 +7,15 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Actions } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
|
||||
@@ -41,6 +44,7 @@ export class RegisterComponent {
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private auth = inject(AuthenticationService);
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
@@ -57,10 +61,12 @@ export class RegisterComponent {
|
||||
password: this.password,
|
||||
displayName: this.displayName.trim(),
|
||||
serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
next: async (resp) => {
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
const homeSignalServerUrl = this.serversSvc.servers().find((server) => server.id === sid)?.url
|
||||
?? this.serversSvc.activeServer()?.url;
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
@@ -68,19 +74,27 @@ export class RegisterComponent {
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now()
|
||||
joinedAt: Date.now(),
|
||||
homeSignalServerUrl
|
||||
};
|
||||
|
||||
this.store.dispatch(UsersActions.authenticateUser({ user }));
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
this.router.navigateByUrl(returnUrl);
|
||||
const outcome = await firstValueFrom(waitForAuthenticationOutcome(this.actions$));
|
||||
|
||||
if (outcome.kind === 'failure') {
|
||||
this.error.set(outcome.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/dashboard']);
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,7 @@
|
||||
|
||||
@if (refreshLoading()) {
|
||||
<div class="pointer-events-none sticky top-0 z-10 flex justify-center py-1">
|
||||
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">
|
||||
Loading...
|
||||
</div>
|
||||
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -87,9 +87,12 @@
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectGif(gif)"
|
||||
[class]="(isMobile()
|
||||
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
|
||||
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30') + ' [content-visibility:auto] [contain-intrinsic-size:auto_180px]'"
|
||||
[class]="
|
||||
(isMobile()
|
||||
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
|
||||
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30') +
|
||||
' [content-visibility:auto] [contain-intrinsic-size:auto_180px]'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<div class="relative">
|
||||
@if (compact()) {
|
||||
<div class="flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
|
||||
@@ -70,7 +69,9 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="mb-3 flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground">
|
||||
<label
|
||||
class="mb-3 flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUpload"
|
||||
class="h-4 w-4"
|
||||
@@ -133,5 +134,4 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@if (session()) {
|
||||
<app-modal-backdrop [zIndex]="120" [dismissable]="false" />
|
||||
<app-modal-backdrop
|
||||
[zIndex]="120"
|
||||
[dismissable]="false"
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
|
||||
<section
|
||||
|
||||
@@ -255,7 +255,9 @@
|
||||
@if (filteredPlugins().length > 0) {
|
||||
<div class="grid gap-3">
|
||||
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
|
||||
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)] [content-visibility:auto] [contain-intrinsic-size:auto_140px]">
|
||||
<article
|
||||
class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)] [content-visibility:auto] [contain-intrinsic-size:auto_140px]"
|
||||
>
|
||||
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
|
||||
@if (plugin.imageUrl && !hasBrokenImage(plugin)) {
|
||||
<img
|
||||
|
||||
@@ -93,9 +93,10 @@ export class ServerDirectoryService {
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number,
|
||||
versions?: ServerEndpointVersions
|
||||
versions?: ServerEndpointVersions,
|
||||
serverTag?: string
|
||||
): void {
|
||||
this.endpointState.updateServerStatus(endpointId, status, latency, versions);
|
||||
this.endpointState.updateServerStatus(endpointId, status, latency, versions, serverTag);
|
||||
}
|
||||
|
||||
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
|
||||
@@ -212,7 +213,8 @@ export class ServerDirectoryService {
|
||||
endpointId,
|
||||
healthResult.status,
|
||||
healthResult.latency,
|
||||
healthResult.versions
|
||||
healthResult.versions,
|
||||
healthResult.serverTag
|
||||
);
|
||||
|
||||
return healthResult.status === 'online';
|
||||
|
||||
@@ -228,7 +228,8 @@ export class ServerEndpointStateService {
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number,
|
||||
versions?: ServerEndpointVersions
|
||||
versions?: ServerEndpointVersions,
|
||||
serverTag?: string
|
||||
): void {
|
||||
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
|
||||
if (endpoint.id !== endpointId) {
|
||||
@@ -240,6 +241,7 @@ export class ServerEndpointStateService {
|
||||
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
|
||||
status,
|
||||
latency,
|
||||
serverTag: serverTag ?? endpoint.serverTag,
|
||||
isActive: status === 'incompatible' && !endpoint.isDefault ? false : endpoint.isActive,
|
||||
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
|
||||
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isSignalServerTagUrl,
|
||||
presentSignalServerTag,
|
||||
resolveEndpointSignalServerTag,
|
||||
resolveUserHomeSignalServerTag
|
||||
} from './signal-server-tag.rules';
|
||||
import type { ServerEndpoint } from '../models/server-directory.model';
|
||||
|
||||
describe('signal-server-tag.rules', () => {
|
||||
const endpoints: ServerEndpoint[] = [
|
||||
{
|
||||
id: 'endpoint-1',
|
||||
name: 'Primary',
|
||||
url: 'http://signal.example.com:3001',
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
status: 'online',
|
||||
serverTag: 'EU'
|
||||
},
|
||||
{
|
||||
id: 'endpoint-2',
|
||||
name: 'Fallback',
|
||||
url: 'https://signal-backup.example.com',
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
status: 'online'
|
||||
}
|
||||
];
|
||||
|
||||
it('uses configured serverTag when present on an endpoint', () => {
|
||||
expect(resolveEndpointSignalServerTag(endpoints[0])).toBe('EU');
|
||||
});
|
||||
|
||||
it('falls back to endpoint url when serverTag is missing', () => {
|
||||
expect(resolveEndpointSignalServerTag(endpoints[1])).toBe('https://signal-backup.example.com');
|
||||
});
|
||||
|
||||
it('resolves a user home signal server tag from known endpoints', () => {
|
||||
expect(resolveUserHomeSignalServerTag('http://signal.example.com:3001/', endpoints)).toBe('EU');
|
||||
});
|
||||
|
||||
it('falls back to the home signal server url when the endpoint is unknown', () => {
|
||||
expect(resolveUserHomeSignalServerTag('http://unknown.example.com:3001', endpoints))
|
||||
.toBe('http://unknown.example.com:3001');
|
||||
});
|
||||
|
||||
it('returns undefined when the user has no home signal server url', () => {
|
||||
expect(resolveUserHomeSignalServerTag(undefined, endpoints)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects http and https values as urls', () => {
|
||||
expect(isSignalServerTagUrl('http://signal.example.com:3001')).toBe(true);
|
||||
expect(isSignalServerTagUrl('https://signal.example.com')).toBe(true);
|
||||
expect(isSignalServerTagUrl('EU')).toBe(false);
|
||||
});
|
||||
|
||||
it('prefixes non-url tags with #', () => {
|
||||
expect(presentSignalServerTag('EU')).toEqual({
|
||||
kind: 'label',
|
||||
label: 'EU',
|
||||
display: '#EU'
|
||||
});
|
||||
});
|
||||
|
||||
it('strips an existing # before re-prefixing label tags', () => {
|
||||
expect(presentSignalServerTag('#sweden')).toEqual({
|
||||
kind: 'label',
|
||||
label: 'sweden',
|
||||
display: '#sweden'
|
||||
});
|
||||
});
|
||||
|
||||
it('presents url tags as globe tooltip targets', () => {
|
||||
expect(presentSignalServerTag('https://signal.example.com')).toEqual({
|
||||
kind: 'url',
|
||||
url: 'https://signal.example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ServerEndpoint } from '../models/server-directory.model';
|
||||
import { sanitiseServerBaseUrl } from './server-endpoint-defaults.logic';
|
||||
|
||||
export type SignalServerTagPresentation =
|
||||
| { kind: 'url'; url: string }
|
||||
| { kind: 'label'; label: string; display: string };
|
||||
|
||||
export function isSignalServerTagUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value.trim());
|
||||
}
|
||||
|
||||
export function presentSignalServerTag(tag: string): SignalServerTagPresentation {
|
||||
const normalized = tag.trim();
|
||||
|
||||
if (isSignalServerTagUrl(normalized)) {
|
||||
return { kind: 'url', url: normalized };
|
||||
}
|
||||
|
||||
const label = normalized.replace(/^#+/, '');
|
||||
|
||||
return { kind: 'label', label, display: `#${label}` };
|
||||
}
|
||||
|
||||
export function resolveEndpointSignalServerTag(
|
||||
endpoint: Pick<ServerEndpoint, 'serverTag' | 'url'>
|
||||
): string {
|
||||
const configuredTag = endpoint.serverTag?.trim();
|
||||
|
||||
return configuredTag || endpoint.url;
|
||||
}
|
||||
|
||||
export function resolveUserHomeSignalServerTag(
|
||||
homeSignalServerUrl: string | undefined,
|
||||
endpoints: ServerEndpoint[]
|
||||
): string | undefined {
|
||||
const normalizedHomeUrl = homeSignalServerUrl?.trim();
|
||||
|
||||
if (!normalizedHomeUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sanitizedHomeUrl = sanitiseServerBaseUrl(normalizedHomeUrl);
|
||||
const matchingEndpoint = endpoints.find(
|
||||
(endpoint) => sanitiseServerBaseUrl(endpoint.url) === sanitizedHomeUrl
|
||||
);
|
||||
|
||||
if (matchingEndpoint) {
|
||||
return resolveEndpointSignalServerTag(matchingEndpoint);
|
||||
}
|
||||
|
||||
return sanitizedHomeUrl;
|
||||
}
|
||||
@@ -57,6 +57,8 @@ export interface ServerEndpoint {
|
||||
instanceId?: string;
|
||||
name: string;
|
||||
url: string;
|
||||
/** Display tag advertised by the signal server health endpoint. */
|
||||
serverTag?: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
defaultKey?: string;
|
||||
@@ -141,10 +143,12 @@ export interface ServerVersionCompatibilityResult {
|
||||
export interface ServerHealthCheckPayload {
|
||||
serverInstanceId?: unknown;
|
||||
serverVersion?: unknown;
|
||||
serverTag?: unknown;
|
||||
}
|
||||
|
||||
export interface ServerEndpointHealthResult {
|
||||
status: ServerEndpointStatus;
|
||||
latency?: number;
|
||||
serverTag?: string;
|
||||
versions?: ServerEndpointVersions;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@
|
||||
|
||||
<div class="relative shrink-0">
|
||||
@if (isJoinedServer(server)) {
|
||||
<div class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500">
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500"
|
||||
>
|
||||
<span class="px-2.5 py-1.5">Joined</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -585,7 +585,8 @@ export class ServerBrowserComponent implements OnInit {
|
||||
await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl));
|
||||
this.webrtc.identify(currentUser.oderId || currentUser.id, currentUser.displayName || 'User', wsUrl, {
|
||||
description: currentUser.description,
|
||||
profileUpdatedAt: currentUser.profileUpdatedAt
|
||||
profileUpdatedAt: currentUser.profileUpdatedAt,
|
||||
homeSignalServerUrl: currentUser.homeSignalServerUrl
|
||||
});
|
||||
|
||||
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
|
||||
|
||||
@@ -33,11 +33,15 @@ export class ServerEndpointHealthService {
|
||||
const serverInstanceId = typeof payload.serverInstanceId === 'string' && payload.serverInstanceId.trim().length > 0
|
||||
? payload.serverInstanceId.trim()
|
||||
: undefined;
|
||||
const serverTag = typeof payload.serverTag === 'string' && payload.serverTag.trim().length > 0
|
||||
? payload.serverTag.trim()
|
||||
: undefined;
|
||||
|
||||
if (!versionCompatibility.isCompatible) {
|
||||
return {
|
||||
status: 'incompatible',
|
||||
latency,
|
||||
serverTag,
|
||||
versions: {
|
||||
serverInstanceId,
|
||||
serverVersion: versionCompatibility.serverVersion,
|
||||
@@ -49,6 +53,7 @@ export class ServerEndpointHealthService {
|
||||
return {
|
||||
status: 'online',
|
||||
latency,
|
||||
serverTag,
|
||||
versions: {
|
||||
serverInstanceId,
|
||||
serverVersion: versionCompatibility.serverVersion,
|
||||
|
||||
@@ -1,408 +1,418 @@
|
||||
<ng-template #pageContent>
|
||||
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
|
||||
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-semibold text-foreground">
|
||||
@if (currentUser()) {
|
||||
Welcome back, {{ currentUser()!.displayName || 'there' }}
|
||||
} @else {
|
||||
Welcome to MetoYou
|
||||
}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<div class="relative">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
aria-label="Search people, servers, and invites"
|
||||
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Search for people, servers, or paste an invite..."
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
(keydown.enter)="submitSearch()"
|
||||
/>
|
||||
<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">
|
||||
Ctrl K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@if (!isSearchMode() && recentSearches().length > 0) {
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-medium text-muted-foreground">Recent:</span>
|
||||
@for (term of recentSearches(); track term) {
|
||||
<span class="group inline-flex items-center gap-1 rounded-full border border-border bg-secondary py-1 pl-3 pr-1 text-xs text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
class="max-w-[10rem] truncate hover:text-primary"
|
||||
(click)="applyRecentSearch(term)"
|
||||
>
|
||||
{{ term }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-4 w-4 place-items-center rounded-full text-muted-foreground hover:bg-card hover:text-foreground"
|
||||
[attr.aria-label]="'Remove ' + term"
|
||||
(click)="removeRecentSearch(term)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
|
||||
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl font-semibold text-foreground">
|
||||
@if (currentUser()) {
|
||||
Welcome back, {{ currentUser()!.displayName || 'there' }}
|
||||
} @else {
|
||||
Welcome to MetoYou
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-muted-foreground hover:text-foreground hover:underline"
|
||||
(click)="clearRecentSearches()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
|
||||
</header>
|
||||
|
||||
@if (isSearchMode()) {
|
||||
<section class="space-y-5">
|
||||
@if (inviteResult(); as invite) {
|
||||
<div>
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invite</h2>
|
||||
<div>
|
||||
<div class="relative">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
aria-label="Search people, servers, and invites"
|
||||
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Search for people, servers, or paste an invite..."
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
(keydown.enter)="submitSearch()"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
Ctrl K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@if (!isSearchMode() && recentSearches().length > 0) {
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-medium text-muted-foreground">Recent:</span>
|
||||
@for (term of recentSearches(); track term) {
|
||||
<span
|
||||
class="group inline-flex items-center gap-1 rounded-full border border-border bg-secondary py-1 pl-3 pr-1 text-xs text-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="max-w-[10rem] truncate hover:text-primary"
|
||||
(click)="applyRecentSearch(term)"
|
||||
>
|
||||
{{ term }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-4 w-4 place-items-center rounded-full text-muted-foreground hover:bg-card hover:text-foreground"
|
||||
[attr.aria-label]="'Remove ' + term"
|
||||
(click)="removeRecentSearch(term)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
(click)="openInvite()"
|
||||
class="text-xs font-medium text-muted-foreground hover:text-foreground hover:underline"
|
||||
(click)="clearRecentSearches()"
|
||||
>
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideTicket"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">Open invite</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (topServerResults().length > 0) {
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</h2>
|
||||
<a
|
||||
routerLink="/servers"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>View all</a
|
||||
@if (isSearchMode()) {
|
||||
<section class="space-y-5">
|
||||
@if (inviteResult(); as invite) {
|
||||
<div>
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invite</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
(click)="openInvite()"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@for (server of topServerResults(); track server.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
(click)="openServer(server)"
|
||||
>
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (server.icon) {
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + server.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ serverInitial(server) }}
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ server.name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
|
||||
</div>
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
name="lucideTicket"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">Open invite</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (topServerResults().length > 0) {
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</h2>
|
||||
<a
|
||||
routerLink="/servers"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>View all</a
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@for (server of topServerResults(); track server.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
(click)="openServer(server)"
|
||||
>
|
||||
<div
|
||||
class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground"
|
||||
>
|
||||
@if (server.icon) {
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + server.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ serverInitial(server) }}
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ server.name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (topPeopleResults().length > 0) {
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">People</h2>
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>View all</a
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@for (person of topPeopleResults(); track person.id) {
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="personLabel(person)"
|
||||
[avatarUrl]="person.avatarUrl"
|
||||
size="md"
|
||||
[status]="person.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ personLabel(person) }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
|
||||
</div>
|
||||
<app-friend-button [user]="person" />
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasNoQuickResults() && !isSearching()) {
|
||||
<div class="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No people, servers, or invites match
|
||||
<span class="font-medium text-foreground">{{ searchQuery() }}</span
|
||||
>.
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<!-- Primary actions -->
|
||||
<section class="grid gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
||||
>
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-foreground">Find People</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Connect with friends.</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a
|
||||
routerLink="/servers"
|
||||
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
||||
>
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
|
||||
<ng-icon
|
||||
name="lucideCompass"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-foreground">Find Servers</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Browse communities.</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a
|
||||
routerLink="/create-server"
|
||||
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
|
||||
>
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-foreground">Create Server</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Start your own.</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-emerald-400 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@if (isNewUser()) {
|
||||
<section class="rounded-xl border border-border bg-card p-6 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-secondary">
|
||||
<ng-icon
|
||||
name="lucideServer"
|
||||
class="h-6 w-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-foreground">Get started</h2>
|
||||
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
You have not joined any servers yet. Find a community to join, or create your own to invite friends.
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (topPeopleResults().length > 0) {
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">People</h2>
|
||||
<!-- People + Popular servers -->
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-xl border border-border bg-card/40 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>View all</a
|
||||
>See all</a
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@for (person of topPeopleResults(); track person.id) {
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="personLabel(person)"
|
||||
[avatarUrl]="person.avatarUrl"
|
||||
size="md"
|
||||
[status]="person.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ personLabel(person) }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
|
||||
@if (peopleYouMightKnow().length > 0) {
|
||||
<div class="space-y-1">
|
||||
@for (person of peopleYouMightKnow(); track person.id) {
|
||||
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
||||
<app-user-avatar
|
||||
[name]="personLabel(person)"
|
||||
[avatarUrl]="person.avatarUrl"
|
||||
size="md"
|
||||
[status]="person.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
|
||||
</div>
|
||||
<app-friend-button [user]="person" />
|
||||
</div>
|
||||
<app-friend-button [user]="person" />
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">No people to suggest yet.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card/40 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
|
||||
<a
|
||||
routerLink="/servers"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>See all</a
|
||||
>
|
||||
</div>
|
||||
@if (popularServers().length > 0) {
|
||||
<div class="space-y-1">
|
||||
@for (server of popularServers(); track server.id) {
|
||||
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
||||
<div
|
||||
class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground"
|
||||
>
|
||||
@if (server.icon) {
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + server.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ serverInitial(server) }}
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ server.name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
(click)="openServer(server)"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">No popular servers right now.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasNoQuickResults() && !isSearching()) {
|
||||
<div class="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No people, servers, or invites match
|
||||
<span class="font-medium text-foreground">{{ searchQuery() }}</span
|
||||
>.
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<!-- Primary actions -->
|
||||
<section class="grid gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
||||
>
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-foreground">Find People</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Connect with friends.</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a
|
||||
routerLink="/servers"
|
||||
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
||||
>
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
|
||||
<ng-icon
|
||||
name="lucideCompass"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-foreground">Find Servers</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Browse communities.</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a
|
||||
routerLink="/create-server"
|
||||
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
|
||||
>
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-foreground">Create Server</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Start your own.</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
class="h-4 w-4 text-emerald-400 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@if (isNewUser()) {
|
||||
<section class="rounded-xl border border-border bg-card p-6 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-secondary">
|
||||
<ng-icon
|
||||
name="lucideServer"
|
||||
class="h-6 w-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-foreground">Get started</h2>
|
||||
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
You have not joined any servers yet. Find a community to join, or create your own to invite friends.
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- People + Popular servers -->
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-xl border border-border bg-card/40 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>See all</a
|
||||
>
|
||||
</div>
|
||||
@if (peopleYouMightKnow().length > 0) {
|
||||
<div class="space-y-1">
|
||||
@for (person of peopleYouMightKnow(); track person.id) {
|
||||
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
||||
<!-- Your friends -->
|
||||
@if (friends().length > 0) {
|
||||
<section>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">Your Friends</h2>
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>Manage</a
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@for (friend of friends(); track friend.id) {
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
|
||||
<app-user-avatar
|
||||
[name]="personLabel(person)"
|
||||
[avatarUrl]="person.avatarUrl"
|
||||
[name]="personLabel(friend)"
|
||||
[avatarUrl]="friend.avatarUrl"
|
||||
size="md"
|
||||
[status]="person.status"
|
||||
[status]="friend.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(friend) }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isOnline(friend) ? 'Online' : 'Offline' }}</p>
|
||||
</div>
|
||||
<app-friend-button [user]="person" />
|
||||
<app-friend-button [user]="friend" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">No people to suggest yet.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="rounded-xl border border-border bg-card/40 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
|
||||
<a
|
||||
routerLink="/servers"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>See all</a
|
||||
>
|
||||
</div>
|
||||
@if (popularServers().length > 0) {
|
||||
<div class="space-y-1">
|
||||
@for (server of popularServers(); track server.id) {
|
||||
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (server.icon) {
|
||||
<!-- Recently active servers -->
|
||||
@if (recentlyActiveServers().length > 0) {
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
@for (room of recentlyActiveServers(); track room.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
(click)="openSavedRoom(room)"
|
||||
>
|
||||
<div
|
||||
class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-secondary text-base font-semibold text-foreground"
|
||||
>
|
||||
@if (room.icon) {
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + server.icon + ')'"
|
||||
[style.backgroundImage]="'url(' + room.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ serverInitial(server) }}
|
||||
{{ room.name[0]?.toUpperCase() || '?' }}
|
||||
}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ server.name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
(click)="openServer(server)"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
<p class="w-full truncate text-sm font-medium text-foreground">{{ room.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ room.userCount }} members</p>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">No popular servers right now.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Your friends -->
|
||||
@if (friends().length > 0) {
|
||||
<section>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">Your Friends</h2>
|
||||
<a
|
||||
routerLink="/people"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>Manage</a
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@for (friend of friends(); track friend.id) {
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
|
||||
<app-user-avatar
|
||||
[name]="personLabel(friend)"
|
||||
[avatarUrl]="friend.avatarUrl"
|
||||
size="md"
|
||||
[status]="friend.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(friend) }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isOnline(friend) ? 'Online' : 'Offline' }}</p>
|
||||
</div>
|
||||
<app-friend-button [user]="friend" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Recently active servers -->
|
||||
@if (recentlyActiveServers().length > 0) {
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
@for (room of recentlyActiveServers(); track room.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
(click)="openSavedRoom(room)"
|
||||
>
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-secondary text-base font-semibold text-foreground">
|
||||
@if (room.icon) {
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + room.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ room.name[0]?.toUpperCase() || '?' }}
|
||||
}
|
||||
</div>
|
||||
<p class="w-full truncate text-sm font-medium text-foreground">{{ room.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ room.userCount }} members</p>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@if (isMobile()) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<article
|
||||
class="flex min-w-0 flex-col items-center justify-center overflow-hidden rounded-xl text-center"
|
||||
[ngClass]="compact() ? 'min-h-[9.5rem] w-[12rem] shrink-0 p-3 sm:w-[14rem] sm:p-4' : 'min-h-[14rem] w-full p-3 sm:min-h-[17rem] sm:p-[clamp(1.25rem,4vw,2rem)]'"
|
||||
[ngClass]="
|
||||
compact()
|
||||
? 'min-h-[9.5rem] w-[12rem] shrink-0 p-3 sm:w-[14rem] sm:p-4'
|
||||
: 'min-h-[14rem] w-full p-3 sm:min-h-[17rem] sm:p-[clamp(1.25rem,4vw,2rem)]'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"
|
||||
|
||||
@@ -19,215 +19,215 @@
|
||||
}
|
||||
|
||||
<ng-template #privateCallSurface>
|
||||
<section
|
||||
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
|
||||
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
|
||||
>
|
||||
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
|
||||
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-3 backdrop-blur sm:px-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
|
||||
<ng-icon
|
||||
name="lucidePhone"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<section
|
||||
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
|
||||
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
|
||||
>
|
||||
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
|
||||
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-3 backdrop-blur sm:px-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
|
||||
<ng-icon
|
||||
name="lucidePhone"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
@if (session()) {
|
||||
{{ participantUsers().length }} participants
|
||||
} @else {
|
||||
Call not found
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
@if (session()) {
|
||||
{{ participantUsers().length }} participants
|
||||
} @else {
|
||||
Call not found
|
||||
@if (session()) {
|
||||
<div class="flex items-center gap-2">
|
||||
@if (isMobile()) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="minimizeCall()"
|
||||
aria-label="Minimize call"
|
||||
title="Minimize call"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session()) {
|
||||
<div class="flex items-center gap-2">
|
||||
@if (isMobile()) {
|
||||
<select
|
||||
class="hidden h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground sm:block"
|
||||
[ngModel]="inviteUserId()"
|
||||
(ngModelChange)="inviteUserId.set($event)"
|
||||
aria-label="Add user to call"
|
||||
>
|
||||
<option value="">Add user</option>
|
||||
@for (user of inviteCandidates(); track userKey(user)) {
|
||||
<option [value]="userKey(user)">{{ user.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="minimizeCall()"
|
||||
aria-label="Minimize call"
|
||||
title="Minimize call"
|
||||
class="hidden h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50 sm:grid"
|
||||
[disabled]="!inviteUserId()"
|
||||
(click)="inviteSelectedUser()"
|
||||
aria-label="Add user"
|
||||
title="Add user"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-5 w-5"
|
||||
name="lucideUserPlus"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
<select
|
||||
class="hidden h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground sm:block"
|
||||
[ngModel]="inviteUserId()"
|
||||
(ngModelChange)="inviteUserId.set($event)"
|
||||
aria-label="Add user to call"
|
||||
>
|
||||
<option value="">Add user</option>
|
||||
@for (user of inviteCandidates(); track userKey(user)) {
|
||||
<option [value]="userKey(user)">{{ user.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50 sm:grid"
|
||||
[disabled]="!inviteUserId()"
|
||||
(click)="inviteSelectedUser()"
|
||||
aria-label="Add user"
|
||||
title="Add user"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (session()) {
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-3 py-3 sm:px-5 sm:py-4">
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
@if (activeShares().length > 0) {
|
||||
@if (focusedShare()) {
|
||||
@if (hasMultipleShares()) {
|
||||
<div class="absolute right-3 top-3 z-10 sm:right-4 sm:top-4">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="private-call-show-all-streams"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
|
||||
title="Show all streams"
|
||||
(click)="showAllStreams()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
All streams
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="focusedShare()!"
|
||||
[featured]="true"
|
||||
[focused]="true"
|
||||
data-testid="private-call-focused-stream"
|
||||
[immersive]="true"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
} @else if (hasMultipleShares()) {
|
||||
<div
|
||||
class="grid h-full min-h-0 auto-rows-[minmax(12rem,1fr)] grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4"
|
||||
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
|
||||
data-testid="private-call-stream-grid"
|
||||
>
|
||||
@for (share of activeShares(); track share.id) {
|
||||
<div class="min-h-0 overflow-hidden rounded-2xl bg-black">
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="share"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
@if (session()) {
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-3 py-3 sm:px-5 sm:py-4">
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
@if (activeShares().length > 0) {
|
||||
@if (focusedShare()) {
|
||||
@if (hasMultipleShares()) {
|
||||
<div class="absolute right-3 top-3 z-10 sm:right-4 sm:top-4">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="private-call-show-all-streams"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
|
||||
title="Show all streams"
|
||||
(click)="showAllStreams()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
All streams
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="focusedShare()!"
|
||||
[featured]="true"
|
||||
[focused]="true"
|
||||
data-testid="private-call-focused-stream"
|
||||
[immersive]="true"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
} @else if (hasMultipleShares()) {
|
||||
<div
|
||||
class="grid h-full min-h-0 auto-rows-[minmax(12rem,1fr)] grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4"
|
||||
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
|
||||
data-testid="private-call-stream-grid"
|
||||
>
|
||||
@for (share of activeShares(); track share.id) {
|
||||
<div class="min-h-0 overflow-hidden rounded-2xl bg-black">
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="share"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex h-full min-h-0 items-center justify-center p-1 sm:p-5">
|
||||
<div
|
||||
class="grid w-full max-w-7xl grid-cols-[repeat(auto-fit,minmax(min(11rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),1fr))] sm:gap-5 lg:gap-7"
|
||||
>
|
||||
@for (user of participantUsers(); track trackUserKey($index, user)) {
|
||||
<app-private-call-participant-card
|
||||
[user]="user"
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex h-full min-h-0 items-center justify-center p-1 sm:p-5">
|
||||
<div
|
||||
class="grid w-full max-w-7xl grid-cols-[repeat(auto-fit,minmax(min(11rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),1fr))] sm:gap-5 lg:gap-7"
|
||||
>
|
||||
</div>
|
||||
|
||||
@if (activeShares().length > 0) {
|
||||
<div class="shrink-0 pt-4">
|
||||
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
|
||||
@for (user of participantUsers(); track trackUserKey($index, user)) {
|
||||
<app-private-call-participant-card
|
||||
[user]="user"
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[compact]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (hasMultipleShares()) {
|
||||
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
|
||||
<article
|
||||
class="flex min-h-[8.75rem] w-[11rem] shrink-0 flex-col overflow-hidden rounded-2xl border border-border/80 bg-black shadow-sm sm:w-[12.5rem]"
|
||||
>
|
||||
<div class="min-h-0 flex-1">
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="share"
|
||||
[mini]="true"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="shrink-0 bg-black/80 px-3 py-2 text-xs font-semibold text-white/75">
|
||||
{{ streamLabel(share) }}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (activeShares().length > 0) {
|
||||
<div class="shrink-0 pt-4">
|
||||
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
|
||||
@for (user of participantUsers(); track trackUserKey($index, user)) {
|
||||
<app-private-call-participant-card
|
||||
[user]="user"
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[compact]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (hasMultipleShares()) {
|
||||
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
|
||||
<article
|
||||
class="flex min-h-[8.75rem] w-[11rem] shrink-0 flex-col overflow-hidden rounded-2xl border border-border/80 bg-black shadow-sm sm:w-[12.5rem]"
|
||||
>
|
||||
<div class="min-h-0 flex-1">
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="share"
|
||||
[mini]="true"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="shrink-0 bg-black/80 px-3 py-2 text-xs font-semibold text-white/75">
|
||||
{{ streamLabel(share) }}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="shrink-0 pt-3">
|
||||
<app-private-call-controls
|
||||
class="mx-auto block w-full max-w-5xl"
|
||||
[connected]="isConnected()"
|
||||
[muted]="isMuted()"
|
||||
[cameraEnabled]="isCameraEnabled()"
|
||||
[screenSharing]="isScreenSharing()"
|
||||
(joinRequested)="join()"
|
||||
(muteToggled)="toggleMute()"
|
||||
(cameraToggled)="toggleCamera()"
|
||||
(screenShareToggled)="toggleScreenShare()"
|
||||
(leaveRequested)="leave()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="shrink-0 pt-3">
|
||||
<app-private-call-controls
|
||||
class="mx-auto block w-full max-w-5xl"
|
||||
[connected]="isConnected()"
|
||||
[muted]="isMuted()"
|
||||
[cameraEnabled]="isCameraEnabled()"
|
||||
[screenSharing]="isScreenSharing()"
|
||||
(joinRequested)="join()"
|
||||
(muteToggled)="toggleMute()"
|
||||
(cameraToggled)="toggleCamera()"
|
||||
(screenShareToggled)="toggleScreenShare()"
|
||||
(leaveRequested)="leave()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
|
||||
}
|
||||
</main>
|
||||
} @else {
|
||||
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
|
||||
}
|
||||
</main>
|
||||
|
||||
<aside class="relative hidden min-h-0 border-l border-border bg-card lg:block">
|
||||
<div
|
||||
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="Resize chat"
|
||||
data-testid="private-call-chat-resizer"
|
||||
(mousedown)="startChatResize($event)"
|
||||
>
|
||||
<div class="mx-auto h-full w-px bg-border transition group-hover:bg-primary"></div>
|
||||
</div>
|
||||
<app-dm-chat
|
||||
[conversationId]="session()?.conversationId ?? null"
|
||||
[showCallButton]="false"
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
<aside class="relative hidden min-h-0 border-l border-border bg-card lg:block">
|
||||
<div
|
||||
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="Resize chat"
|
||||
data-testid="private-call-chat-resizer"
|
||||
(mousedown)="startChatResize($event)"
|
||||
>
|
||||
<div class="mx-auto h-full w-px bg-border transition group-hover:bg-primary"></div>
|
||||
</div>
|
||||
<app-dm-chat
|
||||
[conversationId]="session()?.conversationId ?? null"
|
||||
[showCallButton]="false"
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
</ng-template>
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
|
||||
@@ -124,7 +124,9 @@
|
||||
|
||||
@if (immersive() && item().kind === 'screen' && !isFullscreen()) {
|
||||
<div class="absolute inset-x-3 bottom-3 z-20 sm:inset-x-5 sm:bottom-5">
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 rounded-2xl border border-white/10 bg-black/55 px-3 py-3 text-white/80 shadow-2xl backdrop-blur-lg sm:gap-3 sm:px-4">
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 rounded-2xl border border-white/10 bg-black/55 px-3 py-3 text-white/80 shadow-2xl backdrop-blur-lg sm:gap-3 sm:px-4"
|
||||
>
|
||||
@if (canControlStreamAudio()) {
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-white/10 px-2.5 py-2 sm:max-w-md">
|
||||
<button
|
||||
|
||||
@@ -148,9 +148,8 @@
|
||||
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide
|
||||
apps that get mistakenly identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively
|
||||
against the executable name without its extension.
|
||||
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide apps that get mistakenly
|
||||
identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively against the executable name without its extension.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
applyBrowserDatabaseSchema,
|
||||
ensureObjectStoreDuringUpgrade,
|
||||
ensureStoreIndex
|
||||
} from './browser-database-schema';
|
||||
|
||||
describe('browser-database-schema', () => {
|
||||
it('reuses the upgrade transaction when an object store already exists', () => {
|
||||
const existingStore = { indexNames: { contains: () => false } };
|
||||
const database = {
|
||||
objectStoreNames: { contains: (name: string) => name === 'messages' },
|
||||
createObjectStore: vi.fn(),
|
||||
transaction: vi.fn()
|
||||
};
|
||||
const upgradeTransaction = {
|
||||
objectStore: vi.fn(() => existingStore)
|
||||
};
|
||||
const store = ensureObjectStoreDuringUpgrade(
|
||||
database as unknown as IDBDatabase,
|
||||
upgradeTransaction as unknown as IDBTransaction,
|
||||
'messages',
|
||||
{ keyPath: 'id' }
|
||||
);
|
||||
|
||||
expect(store).toBe(existingStore);
|
||||
expect(upgradeTransaction.objectStore).toHaveBeenCalledWith('messages');
|
||||
expect(database.createObjectStore).not.toHaveBeenCalled();
|
||||
expect(database.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates missing object stores during upgrade', () => {
|
||||
const createdStore = { indexNames: { contains: () => false } };
|
||||
const database = {
|
||||
objectStoreNames: { contains: () => false },
|
||||
createObjectStore: vi.fn(() => createdStore),
|
||||
transaction: vi.fn()
|
||||
};
|
||||
const upgradeTransaction = {
|
||||
objectStore: vi.fn()
|
||||
};
|
||||
const store = ensureObjectStoreDuringUpgrade(
|
||||
database as unknown as IDBDatabase,
|
||||
upgradeTransaction as unknown as IDBTransaction,
|
||||
'customEmojis',
|
||||
{ keyPath: 'id' }
|
||||
);
|
||||
|
||||
expect(store).toBe(createdStore);
|
||||
expect(database.createObjectStore).toHaveBeenCalledWith('customEmojis', { keyPath: 'id' });
|
||||
expect(upgradeTransaction.objectStore).not.toHaveBeenCalled();
|
||||
expect(database.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates missing indexes on an existing store', () => {
|
||||
const store = {
|
||||
indexNames: { contains: () => false },
|
||||
createIndex: vi.fn()
|
||||
};
|
||||
|
||||
ensureStoreIndex(store as unknown as IDBObjectStore, 'roomId', 'roomId');
|
||||
|
||||
expect(store.createIndex).toHaveBeenCalledWith('roomId', 'roomId', { unique: false });
|
||||
});
|
||||
|
||||
it('applies the full schema through the upgrade transaction only', () => {
|
||||
const stores = new Map<string, { indexNames: { contains: (name: string) => boolean }; createIndex: ReturnType<typeof vi.fn> }>();
|
||||
const database = {
|
||||
objectStoreNames: {
|
||||
contains: (name: string) => stores.has(name)
|
||||
},
|
||||
createObjectStore: vi.fn((name: string) => {
|
||||
const store = {
|
||||
indexNames: { contains: () => false },
|
||||
createIndex: vi.fn()
|
||||
};
|
||||
|
||||
stores.set(name, store);
|
||||
|
||||
return store;
|
||||
}),
|
||||
transaction: vi.fn()
|
||||
};
|
||||
const upgradeTransaction = {
|
||||
objectStore: vi.fn((name: string) => {
|
||||
const store = stores.get(name);
|
||||
|
||||
if (!store) {
|
||||
throw new Error(`Missing store ${name}`);
|
||||
}
|
||||
|
||||
return store;
|
||||
})
|
||||
};
|
||||
|
||||
stores.set('messages', {
|
||||
indexNames: { contains: () => true },
|
||||
createIndex: vi.fn()
|
||||
});
|
||||
|
||||
stores.set('users', {
|
||||
indexNames: { contains: () => true },
|
||||
createIndex: vi.fn()
|
||||
});
|
||||
|
||||
expect(() => applyBrowserDatabaseSchema(
|
||||
database as unknown as IDBDatabase,
|
||||
upgradeTransaction as unknown as IDBTransaction
|
||||
)).not.toThrow();
|
||||
|
||||
expect(database.transaction).not.toHaveBeenCalled();
|
||||
expect(upgradeTransaction.objectStore).toHaveBeenCalledWith('messages');
|
||||
expect(database.createObjectStore).toHaveBeenCalledWith('customEmojis', { keyPath: 'id' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||
export const BROWSER_DATABASE_VERSION = 3;
|
||||
|
||||
const STORE_MESSAGES = 'messages';
|
||||
const STORE_USERS = 'users';
|
||||
const STORE_ROOMS = 'rooms';
|
||||
const STORE_REACTIONS = 'reactions';
|
||||
const STORE_BANS = 'bans';
|
||||
const STORE_META = 'meta';
|
||||
const STORE_ATTACHMENTS = 'attachments';
|
||||
const STORE_CUSTOM_EMOJIS = 'customEmojis';
|
||||
|
||||
export function ensureObjectStoreDuringUpgrade(
|
||||
database: IDBDatabase,
|
||||
upgradeTransaction: IDBTransaction,
|
||||
name: string,
|
||||
options?: IDBObjectStoreParameters
|
||||
): IDBObjectStore {
|
||||
if (database.objectStoreNames.contains(name)) {
|
||||
return upgradeTransaction.objectStore(name);
|
||||
}
|
||||
|
||||
return database.createObjectStore(name, options);
|
||||
}
|
||||
|
||||
export function ensureStoreIndex(store: IDBObjectStore, name: string, keyPath: string): void {
|
||||
if (!store.indexNames.contains(name)) {
|
||||
store.createIndex(name, keyPath, { unique: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function applyBrowserDatabaseSchema(
|
||||
database: IDBDatabase,
|
||||
upgradeTransaction: IDBTransaction
|
||||
): void {
|
||||
const messagesStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_MESSAGES, { keyPath: 'id' });
|
||||
|
||||
ensureStoreIndex(messagesStore, 'roomId', 'roomId');
|
||||
ensureStoreIndex(messagesStore, 'timestamp', 'timestamp');
|
||||
|
||||
ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_USERS, { keyPath: 'id' });
|
||||
|
||||
const roomsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_ROOMS, { keyPath: 'id' });
|
||||
|
||||
ensureStoreIndex(roomsStore, 'timestamp', 'timestamp');
|
||||
|
||||
const reactionsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_REACTIONS, { keyPath: 'id' });
|
||||
|
||||
ensureStoreIndex(reactionsStore, 'messageId', 'messageId');
|
||||
ensureStoreIndex(reactionsStore, 'userId', 'userId');
|
||||
|
||||
const bansStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_BANS, { keyPath: 'oderId' });
|
||||
|
||||
ensureStoreIndex(bansStore, 'roomId', 'roomId');
|
||||
ensureStoreIndex(bansStore, 'expiresAt', 'expiresAt');
|
||||
|
||||
ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_META, { keyPath: 'id' });
|
||||
|
||||
const attachmentsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||
|
||||
ensureStoreIndex(attachmentsStore, 'messageId', 'messageId');
|
||||
|
||||
const customEmojisStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
|
||||
|
||||
ensureStoreIndex(customEmojisStore, 'updatedAt', 'updatedAt');
|
||||
ensureStoreIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
|
||||
}
|
||||
@@ -11,12 +11,11 @@ import {
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import type { RoomMessageStats } from './database.service';
|
||||
import { applyBrowserDatabaseSchema, BROWSER_DATABASE_VERSION } from './browser-database-schema';
|
||||
|
||||
/** IndexedDB database name for the MetoYou application. */
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
|
||||
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||
const DATABASE_VERSION = 3;
|
||||
/** Names of every object store used by the application. */
|
||||
const STORE_MESSAGES = 'messages';
|
||||
const STORE_USERS = 'users';
|
||||
@@ -432,10 +431,26 @@ export class BrowserDatabaseService {
|
||||
|
||||
private openDatabase(databaseName: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, DATABASE_VERSION);
|
||||
const request = indexedDB.open(databaseName, BROWSER_DATABASE_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onupgradeneeded = () => this.setupSchema(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const upgradeTransaction = (event.target as IDBOpenDBRequest).transaction;
|
||||
|
||||
if (!upgradeTransaction) {
|
||||
reject(new Error('IndexedDB upgrade transaction is unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
applyBrowserDatabaseSchema(request.result, upgradeTransaction);
|
||||
} catch (error) {
|
||||
upgradeTransaction.abort();
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
@@ -446,58 +461,6 @@ export class BrowserDatabaseService {
|
||||
this.activeDatabaseName = null;
|
||||
}
|
||||
|
||||
private setupSchema(database: IDBDatabase): void {
|
||||
const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(messagesStore, 'roomId', 'roomId');
|
||||
this.ensureIndex(messagesStore, 'timestamp', 'timestamp');
|
||||
|
||||
this.ensureStore(database, STORE_USERS, { keyPath: 'id' });
|
||||
|
||||
const roomsStore = this.ensureStore(database, STORE_ROOMS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(roomsStore, 'timestamp', 'timestamp');
|
||||
|
||||
const reactionsStore = this.ensureStore(database, STORE_REACTIONS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(reactionsStore, 'messageId', 'messageId');
|
||||
this.ensureIndex(reactionsStore, 'userId', 'userId');
|
||||
|
||||
const bansStore = this.ensureStore(database, STORE_BANS, { keyPath: 'oderId' });
|
||||
|
||||
this.ensureIndex(bansStore, 'roomId', 'roomId');
|
||||
this.ensureIndex(bansStore, 'expiresAt', 'expiresAt');
|
||||
|
||||
this.ensureStore(database, STORE_META, { keyPath: 'id' });
|
||||
|
||||
const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(attachmentsStore, 'messageId', 'messageId');
|
||||
|
||||
const customEmojisStore = this.ensureStore(database, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
|
||||
|
||||
this.ensureIndex(customEmojisStore, 'updatedAt', 'updatedAt');
|
||||
this.ensureIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
|
||||
}
|
||||
|
||||
private ensureStore(
|
||||
database: IDBDatabase,
|
||||
name: string,
|
||||
options?: IDBObjectStoreParameters
|
||||
): IDBObjectStore {
|
||||
if (database.objectStoreNames.contains(name)) {
|
||||
return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name);
|
||||
}
|
||||
|
||||
return database.createObjectStore(name, options);
|
||||
}
|
||||
|
||||
private ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void {
|
||||
if (!store.indexNames.contains(name)) {
|
||||
store.createIndex(name, keyPath, { unique: false });
|
||||
}
|
||||
}
|
||||
|
||||
private createTransaction(
|
||||
storeNames: string | string[],
|
||||
mode: IDBTransactionMode
|
||||
|
||||
@@ -158,7 +158,7 @@ sequenceDiagram
|
||||
|
||||
### Reconnection
|
||||
|
||||
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything.
|
||||
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). Each connect attempt is bounded by `SIGNALING_CONNECT_TIMEOUT_MS` (5s); stale sockets stuck in `CONNECTING` are discarded so retries keep running while the server is down. The local dev server and current production builds answer application keepalives with `keepalive_ack`; once a server has answered at least one keepalive the client enforces the ack timeout so half-open sockets do not linger after a process restart. The first keepalive and HTTP health probe run on the first heartbeat tick after connect (not after the full 25s keepalive interval), so localhost restarts are detected in roughly 5–15s instead of waiting for the periodic keepalive cadence. While a socket appears open the client also probes `/api/health` every `SIGNALING_HEALTH_PROBE_INTERVAL_MS` (5s); failed probes, health recovery after an outage, or a changed `serverInstanceId` all force a fresh websocket so zombie connections after a process restart cannot keep stale presence. Failed outbound sends still force a reconnect. On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything, and `RoomsEffects.resyncRoomsOnSignalingReconnect$` re-runs the room join flow as a safety net.
|
||||
|
||||
The browser also sends a lightweight `keepalive` message on the signaling socket during long-lived sessions. The server treats both WebSocket pong frames and any inbound client message as liveness, so users who are still active in voice or chat are not removed from server presence just because control-frame pong delivery stalls behind a proxy or runtime quirk.
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ export function createPeerConnection(
|
||||
|
||||
connection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
if (!callbacks.isSignalingConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('ICE candidate gathered', {
|
||||
remotePeerId,
|
||||
candidateType: (event.candidate as RTCIceCandidate & { type?: string }).type
|
||||
|
||||
@@ -74,6 +74,9 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
|
||||
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
||||
|
||||
private readonly signalingReconnectedSubject$ = new Subject<string>();
|
||||
readonly signalingReconnected$ = this.signalingReconnectedSubject$.asObservable();
|
||||
|
||||
// Delegates to managers
|
||||
get onMessageReceived(): Observable<ChatEvent> {
|
||||
return this.peerMediaFacade.onMessageReceived;
|
||||
@@ -132,8 +135,8 @@ export class WebRTCService implements OnDestroy {
|
||||
getLastJoinedServer,
|
||||
getMemberServerIds
|
||||
),
|
||||
handleConnectionStatus: (_signalUrl, connected, errorMessage) =>
|
||||
this.handleSignalingConnectionStatus(connected, errorMessage),
|
||||
handleConnectionStatus: (signalUrl, connected, errorMessage) =>
|
||||
this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage),
|
||||
handleHeartbeatTick: () => this.peerMediaFacade.broadcastCurrentStates(),
|
||||
handleMessage: (message, signalUrl) => this.handleSignalingMessage(message, signalUrl)
|
||||
});
|
||||
@@ -247,12 +250,16 @@ export class WebRTCService implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
private handleSignalingConnectionStatus(connected: boolean, errorMessage?: string): void {
|
||||
private handleSignalingConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void {
|
||||
this.state.updateSignalingConnectionStatus(
|
||||
this.signalingCoordinator.isAnySignalingConnected(),
|
||||
connected ? true : this.signalingCoordinator.isAnySignalingConnected(),
|
||||
connected,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
if (connected) {
|
||||
this.signalingReconnectedSubject$.next(signalUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
@@ -341,7 +348,7 @@ export class WebRTCService implements OnDestroy {
|
||||
oderId: string,
|
||||
displayName: string,
|
||||
signalUrl?: string,
|
||||
profile?: { description?: string; profileUpdatedAt?: number }
|
||||
profile?: { description?: string; profileUpdatedAt?: number; homeSignalServerUrl?: string }
|
||||
): void {
|
||||
this.signalingTransportHandler.identify(oderId, displayName, signalUrl, profile);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { isTransientSignalingOutboundType } from './realtime.constants';
|
||||
|
||||
describe('isTransientSignalingOutboundType', () => {
|
||||
it('treats negotiation traffic as droppable during reconnect', () => {
|
||||
expect(isTransientSignalingOutboundType('ice_candidate')).toBe(true);
|
||||
expect(isTransientSignalingOutboundType('offer')).toBe(true);
|
||||
expect(isTransientSignalingOutboundType('answer')).toBe(true);
|
||||
expect(isTransientSignalingOutboundType('keepalive')).toBe(true);
|
||||
expect(isTransientSignalingOutboundType('status_update')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps membership traffic as non-transient', () => {
|
||||
expect(isTransientSignalingOutboundType('identify')).toBe(false);
|
||||
expect(isTransientSignalingOutboundType('join_server')).toBe(false);
|
||||
expect(isTransientSignalingOutboundType(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,10 @@ export const PEER_DISCONNECT_GRACE_MS = 10_000;
|
||||
export const STATE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
/** Interval (ms) for application-level signaling keepalive messages */
|
||||
export const SIGNALING_KEEPALIVE_INTERVAL_MS = 25_000;
|
||||
/** How long to wait for a keepalive_ack before treating the socket as dead (ms) */
|
||||
export const SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS = 10_000;
|
||||
/** How often to probe the signaling HTTP health endpoint while connected (ms) */
|
||||
export const SIGNALING_HEALTH_PROBE_INTERVAL_MS = 5_000;
|
||||
/** Interval (ms) for broadcasting voice presence */
|
||||
export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
|
||||
@@ -82,6 +86,20 @@ export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
|
||||
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
|
||||
export const SIGNALING_TYPE_ACCESS_DENIED = 'access_denied';
|
||||
export const SIGNALING_TYPE_KEEPALIVE = 'keepalive';
|
||||
export const SIGNALING_TYPE_KEEPALIVE_ACK = 'keepalive_ack';
|
||||
|
||||
/** Signaling payloads that can be dropped quietly while the socket is reconnecting. */
|
||||
export const TRANSIENT_SIGNALING_OUTBOUND_TYPES = new Set<string>([
|
||||
SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
SIGNALING_TYPE_OFFER,
|
||||
SIGNALING_TYPE_ANSWER,
|
||||
SIGNALING_TYPE_KEEPALIVE,
|
||||
'status_update'
|
||||
]);
|
||||
|
||||
export function isTransientSignalingOutboundType(type: string | undefined): boolean {
|
||||
return typeof type === 'string' && TRANSIENT_SIGNALING_OUTBOUND_TYPES.has(type);
|
||||
}
|
||||
|
||||
export const P2P_TYPE_STATE_REQUEST = 'state-request';
|
||||
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface IdentifyCredentials {
|
||||
description?: string;
|
||||
/** Monotonic profile version for late-join reconciliation. */
|
||||
profileUpdatedAt?: number;
|
||||
/** Public signal-server URL where this user registered. */
|
||||
homeSignalServerUrl?: string;
|
||||
}
|
||||
|
||||
/** Last-joined server info, used for reconnection. */
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { probeSignalingEndpointHealth, resolveSignalingHttpBaseUrl } from './signaling-endpoint-health.rules';
|
||||
|
||||
describe('signaling endpoint health rules', () => {
|
||||
it('maps websocket urls to http health bases', () => {
|
||||
expect(resolveSignalingHttpBaseUrl('wss://46.59.68.77:3001/ws')).toBe('https://46.59.68.77:3001');
|
||||
expect(resolveSignalingHttpBaseUrl('ws://localhost:3001')).toBe('http://localhost:3001');
|
||||
});
|
||||
|
||||
it('treats unreachable endpoints as unhealthy', async () => {
|
||||
const fetchFn = vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
|
||||
await expect(probeSignalingEndpointHealth('ws://localhost:3001', { fetchFn })).resolves.toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('accepts /api/health and falls back to /api/servers', async () => {
|
||||
const fetchFn = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/api/health')) {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/servers')) {
|
||||
return new Response('[]', { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
});
|
||||
|
||||
await expect(probeSignalingEndpointHealth('ws://localhost:3001', { fetchFn })).resolves.toEqual({ ok: true });
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns serverInstanceId from /api/health when available', async () => {
|
||||
const fetchFn = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/api/health')) {
|
||||
return new Response(JSON.stringify({ serverInstanceId: 'instance-a' }), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
});
|
||||
|
||||
await expect(probeSignalingEndpointHealth('ws://localhost:3001', { fetchFn })).resolves.toEqual({
|
||||
ok: true,
|
||||
serverInstanceId: 'instance-a'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
export interface SignalingEndpointHealthSnapshot {
|
||||
ok: boolean;
|
||||
serverInstanceId?: string;
|
||||
}
|
||||
|
||||
export function resolveSignalingHttpBaseUrl(signalUrl: string | null | undefined): string | null {
|
||||
if (!signalUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(signalUrl);
|
||||
|
||||
if (parsed.protocol === 'wss:') {
|
||||
parsed.protocol = 'https:';
|
||||
} else if (parsed.protocol === 'ws:') {
|
||||
parsed.protocol = 'http:';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
parsed.pathname = '';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
|
||||
return parsed.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readHealthSnapshot(
|
||||
baseUrl: string,
|
||||
fetchFn: typeof fetch,
|
||||
timeoutMs: number
|
||||
): Promise<SignalingEndpointHealthSnapshot | null> {
|
||||
try {
|
||||
const response = await fetchFn(`${baseUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json() as { serverInstanceId?: unknown };
|
||||
const serverInstanceId = typeof payload.serverInstanceId === 'string' && payload.serverInstanceId.trim().length > 0
|
||||
? payload.serverInstanceId.trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
serverInstanceId
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeSignalingEndpointHealth(
|
||||
signalUrl: string | null | undefined,
|
||||
options: {
|
||||
fetchFn?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
} = {}
|
||||
): Promise<SignalingEndpointHealthSnapshot> {
|
||||
const baseUrl = resolveSignalingHttpBaseUrl(signalUrl);
|
||||
|
||||
if (!baseUrl) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const fetchFn = options.fetchFn ?? fetch;
|
||||
const timeoutMs = options.timeoutMs ?? 5_000;
|
||||
const healthSnapshot = await readHealthSnapshot(baseUrl, fetchFn, timeoutMs);
|
||||
|
||||
if (healthSnapshot?.ok) {
|
||||
return healthSnapshot;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchFn(`${baseUrl}/api/servers`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
|
||||
return { ok: response.ok };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Observable, of } from 'rxjs';
|
||||
import type { SignalingMessage } from '../../../shared-kernel';
|
||||
import { DEFAULT_DISPLAY_NAME, SIGNALING_TYPE_IDENTIFY } from '../realtime.constants';
|
||||
import {
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
isTransientSignalingOutboundType
|
||||
} from '../realtime.constants';
|
||||
import { IdentifyCredentials } from '../realtime.types';
|
||||
import { ConnectedSignalingManager, ServerSignalingCoordinator } from './server-signaling-coordinator';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
@@ -108,6 +112,10 @@ export class SignalingTransportHandler<TMessage> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTransientSignalingOutboundType(messageType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.warn('[signaling] Missing peer signal route for outbound raw message', {
|
||||
targetPeerId,
|
||||
type: messageType
|
||||
@@ -134,15 +142,11 @@ export class SignalingTransportHandler<TMessage> {
|
||||
const connectedManagers = this.getConnectedSignalingManagers();
|
||||
|
||||
if (connectedManagers.length === 0) {
|
||||
if (messageType === 'status_update') {
|
||||
this.dependencies.logger.warn('[signaling] Skipping status update without an active signaling connection', {
|
||||
type: messageType
|
||||
});
|
||||
|
||||
if (isTransientSignalingOutboundType(messageType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dependencies.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
||||
this.dependencies.logger.warn('[signaling] No active signaling connection for outbound message', {
|
||||
type: messageType
|
||||
});
|
||||
|
||||
@@ -164,19 +168,18 @@ export class SignalingTransportHandler<TMessage> {
|
||||
sendRawMessageToSignalUrl(signalUrl: string, message: Record<string, unknown>): boolean {
|
||||
const manager = this.dependencies.signalingCoordinator.getSignalingManager(signalUrl);
|
||||
|
||||
if (!manager) {
|
||||
if (!manager || !manager.isSocketOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
manager.sendRawMessage(message);
|
||||
return true;
|
||||
return manager.sendRawMessage(message);
|
||||
}
|
||||
|
||||
identify(
|
||||
oderId: string,
|
||||
displayName: string,
|
||||
signalUrl?: string,
|
||||
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt'>
|
||||
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt' | 'homeSignalServerUrl'>
|
||||
): void {
|
||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||
const normalizedDescription = typeof profile?.description === 'string'
|
||||
@@ -187,12 +190,16 @@ export class SignalingTransportHandler<TMessage> {
|
||||
&& profile.profileUpdatedAt > 0
|
||||
? profile.profileUpdatedAt
|
||||
: undefined;
|
||||
const normalizedHomeSignalServerUrl = typeof profile?.homeSignalServerUrl === 'string'
|
||||
? (profile.homeSignalServerUrl.trim().replace(/\/+$/, '') || undefined)
|
||||
: undefined;
|
||||
|
||||
this.lastIdentifyCredentials = {
|
||||
oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl
|
||||
};
|
||||
|
||||
if (signalUrl) {
|
||||
@@ -202,6 +209,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: signalUrl
|
||||
});
|
||||
|
||||
@@ -221,6 +229,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
connectionScope: managerSignalUrl
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
SIGNALING_CONNECT_TIMEOUT_MS,
|
||||
SIGNALING_HEALTH_PROBE_INTERVAL_MS,
|
||||
SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
|
||||
SIGNALING_KEEPALIVE_INTERVAL_MS,
|
||||
SIGNALING_RECONNECT_BASE_DELAY_MS,
|
||||
STATE_HEARTBEAT_INTERVAL_MS
|
||||
} from '../realtime.constants';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
import { SignalingManager } from './signaling.manager';
|
||||
import { probeSignalingEndpointHealth } from './signaling-endpoint-health.rules';
|
||||
|
||||
vi.mock('./signaling-endpoint-health.rules', () => ({
|
||||
probeSignalingEndpointHealth: vi.fn(async () => ({ ok: true, serverInstanceId: 'instance-a' }))
|
||||
}));
|
||||
|
||||
type MockSocketHandler = (event?: { code?: number; reason?: string; wasClean?: boolean }) => void;
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
shouldFailSend = false;
|
||||
autoAckKeepalive = true;
|
||||
readonly url: string;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: MockSocketHandler | null = null;
|
||||
onclose: MockSocketHandler | null = null;
|
||||
onerror: MockSocketHandler | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === MockWebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.({ code: 1000, reason: 'closed', wasClean: true });
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
if (this.shouldFailSend) {
|
||||
throw new Error('mock send failure');
|
||||
}
|
||||
|
||||
if (!this.autoAckKeepalive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(payload) as { type?: string };
|
||||
|
||||
if (message.type === 'keepalive') {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: 'keepalive_ack' }) });
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads in tests
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen(): void {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
simulateError(): void {
|
||||
this.onerror?.(new Event('error'));
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('SignalingManager reconnection', () => {
|
||||
const serverUrl = 'ws://localhost:3001';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: true, serverInstanceId: 'instance-a' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createManager(
|
||||
identifyCredentials: {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
} = {
|
||||
oderId: 'peer-a',
|
||||
displayName: 'Peer A',
|
||||
description: 'hello',
|
||||
profileUpdatedAt: 42,
|
||||
homeSignalServerUrl: 'https://signal.example'
|
||||
}
|
||||
): SignalingManager {
|
||||
const sentMessages: Record<string, unknown>[] = [];
|
||||
const manager = new SignalingManager(
|
||||
new WebRTCLogger(false),
|
||||
() => identifyCredentials,
|
||||
() => ({ serverId: 'server-1', userId: 'peer-a' }),
|
||||
() => new Set(['server-1'])
|
||||
);
|
||||
const originalSendRawMessage = manager.sendRawMessage.bind(manager);
|
||||
|
||||
manager.sendRawMessage = (message: Record<string, unknown>) => {
|
||||
sentMessages.push(message);
|
||||
originalSendRawMessage(message);
|
||||
};
|
||||
|
||||
Object.assign(manager, { sentMessages });
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
it('schedules reconnect after an established socket closes', async () => {
|
||||
const manager = createManager();
|
||||
const connected = vi.fn();
|
||||
|
||||
manager.connect(serverUrl).subscribe({ next: connected });
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
expect(connected).toHaveBeenCalledWith(true);
|
||||
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
expect(MockWebSocket.instances[1]?.readyState).toBe(MockWebSocket.CONNECTING);
|
||||
});
|
||||
|
||||
it('keeps retrying after a failed reconnect attempt until the server accepts the socket', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
MockWebSocket.instances[1]?.simulateError();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
||||
expect(MockWebSocket.instances).toHaveLength(3);
|
||||
|
||||
MockWebSocket.instances[2]?.simulateOpen();
|
||||
expect(manager.isSocketOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('discards a stale connecting socket and retries after the connect timeout', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('does not treat a timed-out waitForOpen result as a successful reconnect', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
|
||||
const secondAttempt = vi.fn();
|
||||
|
||||
manager.connect(serverUrl).subscribe({ next: secondAttempt });
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
|
||||
expect(secondAttempt).toHaveBeenCalledWith(false);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('replays the full identify payload after reconnect', () => {
|
||||
const manager = createManager() as SignalingManager & { sentMessages: Record<string, unknown>[] };
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
manager.sentMessages.length = 0;
|
||||
MockWebSocket.instances[0]?.close();
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
MockWebSocket.instances[1]?.simulateOpen();
|
||||
|
||||
const identifyMessage = manager.sentMessages.find((message) => message['type'] === 'identify');
|
||||
|
||||
expect(identifyMessage).toMatchObject({
|
||||
type: 'identify',
|
||||
oderId: 'peer-a',
|
||||
displayName: 'Peer A',
|
||||
description: 'hello',
|
||||
profileUpdatedAt: 42,
|
||||
homeSignalServerUrl: 'https://signal.example'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not force reconnect on servers that never send keepalive acknowledgements', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
const socket = MockWebSocket.instances[0];
|
||||
|
||||
if (socket) {
|
||||
socket.autoAckKeepalive = false;
|
||||
}
|
||||
|
||||
socket?.simulateOpen();
|
||||
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(manager.isSocketOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('forces reconnect when keepalive acknowledgements stop arriving on ack-capable servers', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
const socket = MockWebSocket.instances[0];
|
||||
|
||||
socket?.simulateOpen();
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
if (socket) {
|
||||
socket.autoAckKeepalive = false;
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('silently drops transient negotiation messages while the socket is reconnecting', () => {
|
||||
const logger = new WebRTCLogger(false);
|
||||
const errorSpy = vi.spyOn(logger, 'error');
|
||||
const manager = new SignalingManager(
|
||||
logger,
|
||||
() => ({ oderId: 'peer-a', displayName: 'Peer A' }),
|
||||
() => ({ serverId: 'server-1', userId: 'peer-a' }),
|
||||
() => new Set(['server-1'])
|
||||
);
|
||||
|
||||
expect(manager.sendRawMessage({
|
||||
type: 'ice_candidate',
|
||||
targetUserId: 'peer-b',
|
||||
payload: { candidate: { candidate: 'candidate:1' } }
|
||||
})).toBe(false);
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces reconnect when the signaling health probe fails on an open socket', async () => {
|
||||
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: false });
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('forces reconnect when the signaling server instance changes under an open socket', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
MockWebSocket.instances[0]?.simulateOpen();
|
||||
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
await Promise.resolve();
|
||||
|
||||
vi.mocked(probeSignalingEndpointHealth).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
serverInstanceId: 'instance-b'
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('forces reconnect when outbound signaling sends fail on a stale socket', () => {
|
||||
const manager = createManager();
|
||||
|
||||
manager.connect(serverUrl).subscribe();
|
||||
const socket = MockWebSocket.instances[0];
|
||||
|
||||
socket?.simulateOpen();
|
||||
|
||||
if (socket) {
|
||||
socket.shouldFailSend = true;
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
|
||||
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
|
||||
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manager.isSocketOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,16 @@ import {
|
||||
SIGNALING_CONNECT_TIMEOUT_MS,
|
||||
STATE_HEARTBEAT_INTERVAL_MS,
|
||||
SIGNALING_KEEPALIVE_INTERVAL_MS,
|
||||
SIGNALING_HEALTH_PROBE_INTERVAL_MS,
|
||||
SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_KEEPALIVE,
|
||||
SIGNALING_TYPE_VIEW_SERVER
|
||||
SIGNALING_TYPE_KEEPALIVE_ACK,
|
||||
SIGNALING_TYPE_VIEW_SERVER,
|
||||
isTransientSignalingOutboundType
|
||||
} from '../realtime.constants';
|
||||
import { probeSignalingEndpointHealth } from './signaling-endpoint-health.rules';
|
||||
|
||||
interface ParsedSignalingPayload {
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
@@ -42,6 +47,13 @@ export class SignalingManager {
|
||||
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastKeepaliveSentAt = 0;
|
||||
private lastKeepaliveAckAt = 0;
|
||||
private serverSupportsKeepaliveAck = false;
|
||||
private lastEndpointHealthOk: boolean | null = null;
|
||||
private lastKnownServerInstanceId: string | null = null;
|
||||
private lastEndpointHealthProbeAt = 0;
|
||||
private endpointHealthProbeInFlight = false;
|
||||
private connectAttemptStartedAt = 0;
|
||||
|
||||
/** Fires every heartbeat tick - the main service hooks this to broadcast state. */
|
||||
readonly heartbeatTick$ = new Subject<void>();
|
||||
@@ -67,23 +79,43 @@ export class SignalingManager {
|
||||
}
|
||||
|
||||
if (this.isSocketConnecting()) {
|
||||
return this.waitForOpen();
|
||||
const connectAge = Date.now() - this.connectAttemptStartedAt;
|
||||
|
||||
if (connectAge < SIGNALING_CONNECT_TIMEOUT_MS) {
|
||||
return this.waitForOpen();
|
||||
}
|
||||
|
||||
this.discardCurrentSocket();
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSignalingUrl = serverUrl;
|
||||
return new Observable<boolean>((observer) => {
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearConnectTimeout = (): void => {
|
||||
if (!connectTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(connectTimeout);
|
||||
connectTimeout = null;
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.info('[signaling] Connecting to signaling server', { serverUrl });
|
||||
|
||||
const previousSocket = this.signalingWebSocket;
|
||||
|
||||
this.lastSignalingUrl = serverUrl;
|
||||
this.connectAttemptStartedAt = Date.now();
|
||||
const socket = new WebSocket(serverUrl);
|
||||
|
||||
this.signalingWebSocket = socket;
|
||||
|
||||
if (previousSocket && previousSocket !== socket) {
|
||||
this.detachSocketHandlers(previousSocket);
|
||||
|
||||
try {
|
||||
previousSocket.close();
|
||||
} catch {
|
||||
@@ -93,16 +125,44 @@ export class SignalingManager {
|
||||
}
|
||||
}
|
||||
|
||||
connectTimeout = setTimeout(() => {
|
||||
if (socket !== this.signalingWebSocket || !this.isSocketConnecting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn('[signaling] Signaling connect attempt timed out', {
|
||||
timeoutMs: SIGNALING_CONNECT_TIMEOUT_MS,
|
||||
url: serverUrl
|
||||
});
|
||||
|
||||
clearConnectTimeout();
|
||||
this.discardCurrentSocket();
|
||||
this.connectionStatus$.next({
|
||||
connected: false,
|
||||
errorMessage: 'Timed out connecting to signaling server'
|
||||
});
|
||||
|
||||
this.scheduleReconnect();
|
||||
observer.next(false);
|
||||
observer.complete();
|
||||
}, SIGNALING_CONNECT_TIMEOUT_MS);
|
||||
|
||||
socket.onopen = () => {
|
||||
if (socket !== this.signalingWebSocket)
|
||||
return;
|
||||
|
||||
clearConnectTimeout();
|
||||
|
||||
this.logger.info('[signaling] Connected to signaling server', {
|
||||
serverUrl,
|
||||
readyState: this.getSocketReadyStateLabel()
|
||||
});
|
||||
|
||||
this.clearReconnect();
|
||||
this.lastKeepaliveAckAt = Date.now();
|
||||
this.lastEndpointHealthOk = null;
|
||||
this.lastKnownServerInstanceId = null;
|
||||
this.lastEndpointHealthProbeAt = 0;
|
||||
this.startHeartbeat();
|
||||
this.connectionStatus$.next({ connected: true });
|
||||
this.reIdentifyAndRejoin();
|
||||
@@ -132,7 +192,12 @@ export class SignalingManager {
|
||||
url: serverUrl
|
||||
});
|
||||
|
||||
this.messageReceived$.next(message);
|
||||
if (message.type === SIGNALING_TYPE_KEEPALIVE_ACK) {
|
||||
this.serverSupportsKeepaliveAck = true;
|
||||
this.lastKeepaliveAckAt = Date.now();
|
||||
} else {
|
||||
this.messageReceived$.next(message);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('[signaling] Failed to parse signaling message', error, {
|
||||
bytes: payloadBytes ?? undefined,
|
||||
@@ -147,6 +212,8 @@ export class SignalingManager {
|
||||
if (socket !== this.signalingWebSocket)
|
||||
return;
|
||||
|
||||
clearConnectTimeout();
|
||||
|
||||
this.logger.error('[signaling] Signaling socket error', error, {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: serverUrl
|
||||
@@ -162,6 +229,8 @@ export class SignalingManager {
|
||||
if (socket !== this.signalingWebSocket)
|
||||
return;
|
||||
|
||||
clearConnectTimeout();
|
||||
|
||||
this.logger.warn('[signaling] Disconnected from signaling server', {
|
||||
attempts: this.signalingReconnectAttempts,
|
||||
code: event.code,
|
||||
@@ -203,7 +272,13 @@ export class SignalingManager {
|
||||
}, timeoutMs);
|
||||
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } },
|
||||
next: (connected) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(connected);
|
||||
}
|
||||
},
|
||||
error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } }
|
||||
});
|
||||
});
|
||||
@@ -212,7 +287,11 @@ export class SignalingManager {
|
||||
/** Send a signaling message (with `from` / `timestamp` populated). */
|
||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>, localPeerId: string): void {
|
||||
if (!this.isSocketOpen()) {
|
||||
this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), {
|
||||
if (isTransientSignalingOutboundType(message.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn('[signaling] Signaling socket not connected', {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
type: message.type,
|
||||
url: this.lastSignalingUrl
|
||||
@@ -233,22 +312,30 @@ export class SignalingManager {
|
||||
}
|
||||
|
||||
/** Send a raw JSON payload (for identify, join_server, etc.). */
|
||||
sendRawMessage(message: Record<string, unknown>): void {
|
||||
sendRawMessage(message: Record<string, unknown>): boolean {
|
||||
const messageType = typeof message['type'] === 'string' ? message['type'] : 'unknown';
|
||||
|
||||
if (!this.isSocketOpen()) {
|
||||
this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), {
|
||||
if (isTransientSignalingOutboundType(messageType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.warn('[signaling] Signaling socket not connected', {
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
type: typeof message['type'] === 'string' ? message['type'] : 'unknown',
|
||||
type: messageType,
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.sendSerializedPayload(message, {
|
||||
targetPeerId: typeof message['targetUserId'] === 'string' ? message['targetUserId'] : undefined,
|
||||
type: typeof message['type'] === 'string' ? message['type'] : 'unknown',
|
||||
type: messageType,
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gracefully close the WebSocket. */
|
||||
@@ -284,10 +371,15 @@ export class SignalingManager {
|
||||
const credentials = this.getLastIdentify();
|
||||
|
||||
if (credentials) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||
this.sendRawMessage({
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName,
|
||||
connectionScope: this.lastSignalingUrl ?? undefined });
|
||||
description: credentials.description,
|
||||
profileUpdatedAt: credentials.profileUpdatedAt,
|
||||
homeSignalServerUrl: credentials.homeSignalServerUrl,
|
||||
connectionScope: this.lastSignalingUrl ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
const memberIds = this.getMemberServerIds();
|
||||
@@ -337,7 +429,14 @@ export class SignalingManager {
|
||||
});
|
||||
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
next: () => { this.signalingReconnectAttempts = 0; },
|
||||
next: (connected) => {
|
||||
if (connected) {
|
||||
this.signalingReconnectAttempts = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
error: () => { this.scheduleReconnect(); }
|
||||
});
|
||||
}, delay);
|
||||
@@ -381,6 +480,56 @@ export class SignalingManager {
|
||||
});
|
||||
}
|
||||
|
||||
private handleSocketTransportFailure(reason: string, error?: unknown): void {
|
||||
if (!this.signalingWebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn('[signaling] Signaling transport failed; forcing reconnect', {
|
||||
error,
|
||||
reason,
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
|
||||
this.stopHeartbeat();
|
||||
this.discardCurrentSocket();
|
||||
this.connectionStatus$.next({
|
||||
connected: false,
|
||||
errorMessage: reason
|
||||
});
|
||||
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private discardCurrentSocket(): void {
|
||||
const socket = this.signalingWebSocket;
|
||||
|
||||
this.signalingWebSocket = null;
|
||||
this.connectAttemptStartedAt = 0;
|
||||
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.detachSocketHandlers(socket);
|
||||
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
this.logger.warn('[signaling] Failed to discard signaling socket', {
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private detachSocketHandlers(socket: WebSocket): void {
|
||||
socket.onopen = null;
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
socket.onmessage = null;
|
||||
}
|
||||
|
||||
/** Cancel any pending reconnect timer and reset the attempt counter. */
|
||||
private clearReconnect(): void {
|
||||
if (this.signalingReconnectTimer) {
|
||||
@@ -394,11 +543,22 @@ export class SignalingManager {
|
||||
/** Start the heartbeat interval that drives periodic state broadcasts. */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.lastKeepaliveSentAt = Date.now();
|
||||
// Prime timers so the first heartbeat tick can send keepalives and health probes immediately.
|
||||
const now = Date.now();
|
||||
|
||||
this.lastKeepaliveSentAt = now - SIGNALING_KEEPALIVE_INTERVAL_MS;
|
||||
this.lastEndpointHealthProbeAt = now - SIGNALING_HEALTH_PROBE_INTERVAL_MS;
|
||||
this.stateHeartbeatTimer = setInterval(() => {
|
||||
this.heartbeatTick$.next();
|
||||
this.sendKeepaliveIfDue();
|
||||
this.runHeartbeatChecks();
|
||||
}, STATE_HEARTBEAT_INTERVAL_MS);
|
||||
void this.runHeartbeatChecks();
|
||||
}
|
||||
|
||||
private runHeartbeatChecks(): void {
|
||||
this.heartbeatTick$.next();
|
||||
this.verifyKeepaliveAckIfDue();
|
||||
this.sendKeepaliveIfDue();
|
||||
void this.probeEndpointHealthIfDue();
|
||||
}
|
||||
|
||||
/** Stop the heartbeat interval. */
|
||||
@@ -409,6 +569,75 @@ export class SignalingManager {
|
||||
}
|
||||
|
||||
this.lastKeepaliveSentAt = 0;
|
||||
this.lastKeepaliveAckAt = 0;
|
||||
this.serverSupportsKeepaliveAck = false;
|
||||
this.lastEndpointHealthOk = null;
|
||||
this.lastKnownServerInstanceId = null;
|
||||
this.lastEndpointHealthProbeAt = 0;
|
||||
this.endpointHealthProbeInFlight = false;
|
||||
}
|
||||
|
||||
private async probeEndpointHealthIfDue(): Promise<void> {
|
||||
if (!this.lastSignalingUrl || this.endpointHealthProbeInFlight || this.isSocketConnecting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isSocketOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastEndpointHealthProbeAt < SIGNALING_HEALTH_PROBE_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastEndpointHealthProbeAt = now;
|
||||
this.endpointHealthProbeInFlight = true;
|
||||
|
||||
try {
|
||||
const snapshot = await probeSignalingEndpointHealth(this.lastSignalingUrl);
|
||||
const wasHealthy = this.lastEndpointHealthOk;
|
||||
const previousServerInstanceId = this.lastKnownServerInstanceId;
|
||||
|
||||
this.lastEndpointHealthOk = snapshot.ok;
|
||||
|
||||
if (!snapshot.ok) {
|
||||
this.handleSocketTransportFailure('Signaling server health check failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.serverInstanceId) {
|
||||
if (previousServerInstanceId && snapshot.serverInstanceId !== previousServerInstanceId) {
|
||||
this.handleSocketTransportFailure('Signaling server instance changed; refreshing websocket');
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastKnownServerInstanceId = snapshot.serverInstanceId;
|
||||
}
|
||||
|
||||
if (wasHealthy === false) {
|
||||
this.handleSocketTransportFailure('Signaling server recovered; refreshing websocket');
|
||||
}
|
||||
} finally {
|
||||
this.endpointHealthProbeInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private verifyKeepaliveAckIfDue(): void {
|
||||
if (!this.serverSupportsKeepaliveAck || !this.isSocketOpen() || this.lastKeepaliveSentAt === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lastKeepaliveAckAt >= this.lastKeepaliveSentAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - this.lastKeepaliveSentAt < SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleSocketTransportFailure('Signaling keepalive acknowledgement timed out');
|
||||
}
|
||||
|
||||
private sendKeepaliveIfDue(): void {
|
||||
@@ -421,13 +650,13 @@ export class SignalingManager {
|
||||
this.lastKeepaliveSentAt = now;
|
||||
|
||||
try {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_KEEPALIVE });
|
||||
const sent = this.sendRawMessage({ type: SIGNALING_TYPE_KEEPALIVE });
|
||||
|
||||
if (sent && !this.serverSupportsKeepaliveAck) {
|
||||
this.lastKeepaliveAckAt = this.lastKeepaliveSentAt;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('[signaling] Failed to send signaling keepalive', {
|
||||
error,
|
||||
readyState: this.getSocketReadyStateLabel(),
|
||||
url: this.lastSignalingUrl
|
||||
});
|
||||
this.handleSocketTransportFailure('Failed to send signaling keepalive', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +711,8 @@ export class SignalingManager {
|
||||
url: details.url
|
||||
});
|
||||
|
||||
this.handleSocketTransportFailure('Failed to send signaling payload', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface User {
|
||||
isAdmin?: boolean;
|
||||
isRoomOwner?: boolean;
|
||||
presenceServerIds?: string[];
|
||||
/** Public signal-server URL where this user registered. */
|
||||
homeSignalServerUrl?: string;
|
||||
voiceState?: VoiceState;
|
||||
screenShareState?: ScreenShareState;
|
||||
cameraState?: CameraState;
|
||||
|
||||
@@ -73,8 +73,11 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (profileUser.username && profileUser.username !== profileUser.displayName) {
|
||||
<p class="mt-0.5 text-sm text-muted-foreground">{{ '@' + profileUser.username }}</p>
|
||||
@if ((profileUser.username && profileUser.username !== profileUser.displayName) || signalServerTag()) {
|
||||
<p class="mt-0.5 flex items-center justify-center gap-1.5 text-sm leading-none text-muted-foreground">
|
||||
<span class="leading-none">{{ '@' + profileUser.username }}</span>
|
||||
<app-profile-signal-server-tag [tag]="signalServerTag()" />
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (isEditable) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
lucideUserPlus
|
||||
} from '@ng-icons/lucide';
|
||||
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
|
||||
import { ProfileSignalServerTagComponent } from './profile-signal-server-tag.component';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { User, UserStatus } from '../../../shared-kernel';
|
||||
import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors';
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
ProfileAvatarEditorService,
|
||||
ProfileAvatarFacade
|
||||
} from '../../../domains/profile-avatar';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { resolveUserHomeSignalServerTag } from '../../../domains/server-directory/domain/logic/signal-server-tag.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-card-mobile',
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ProfileSignalServerTagComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -85,6 +89,7 @@ export class ProfileCardMobileComponent implements OnDestroy {
|
||||
];
|
||||
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
@@ -103,6 +108,12 @@ export class ProfileCardMobileComponent implements OnDestroy {
|
||||
|
||||
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
|
||||
});
|
||||
readonly signalServerTag = computed(() =>
|
||||
resolveUserHomeSignalServerTag(
|
||||
this.displayedUser().homeSignalServerUrl,
|
||||
this.serverDirectory.servers()
|
||||
)
|
||||
);
|
||||
|
||||
readonly isSelf = computed(() => {
|
||||
const me = this.currentUser();
|
||||
|
||||
@@ -65,7 +65,10 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||
<p class="flex min-w-0 items-center gap-1.5 truncate text-sm leading-none text-muted-foreground">
|
||||
<span class="truncate leading-none">{{ profileUser.username }}</span>
|
||||
<app-profile-signal-server-tag [tag]="signalServerTag()" />
|
||||
</p>
|
||||
@if (profileUser.gameActivity; as activity) {
|
||||
<p class="mt-1 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<ng-icon
|
||||
@@ -149,7 +152,10 @@
|
||||
</div>
|
||||
} @else {
|
||||
<p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p>
|
||||
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
|
||||
<p class="flex min-w-0 items-center gap-1.5 truncate text-sm leading-none text-muted-foreground">
|
||||
<span class="truncate leading-none">{{ profileUser.username }}</span>
|
||||
<app-profile-signal-server-tag [tag]="signalServerTag()" />
|
||||
</p>
|
||||
|
||||
@if (profileUser.gameActivity; as activity) {
|
||||
<div class="mt-3 flex items-center gap-2 rounded-md border border-border bg-background/40 px-2.5 py-2">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
lucideGamepad2
|
||||
} from '@ng-icons/lucide';
|
||||
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
|
||||
import { ProfileSignalServerTagComponent } from './profile-signal-server-tag.component';
|
||||
import { UserStatusService } from '../../../core/services/user-status.service';
|
||||
import {
|
||||
GameActivity,
|
||||
@@ -35,6 +36,8 @@ import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { formatGameActivityElapsed } from '../../../domains/game-activity';
|
||||
import { ExternalLinkService } from '../../../core/platform/external-link.service';
|
||||
import { visibilityAwareInterval$ } from '../../rxjs';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { resolveUserHomeSignalServerTag } from '../../../domains/server-directory/domain/logic/signal-server-tag.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-card',
|
||||
@@ -43,6 +46,7 @@ import { visibilityAwareInterval$ } from '../../rxjs';
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ProfileSignalServerTagComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })],
|
||||
@@ -57,6 +61,12 @@ export class ProfileCardComponent {
|
||||
|
||||
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
|
||||
});
|
||||
readonly signalServerTag = computed(() =>
|
||||
resolveUserHomeSignalServerTag(
|
||||
this.displayedUser().homeSignalServerUrl,
|
||||
this.serverDirectory.servers()
|
||||
)
|
||||
);
|
||||
readonly editable = signal(false);
|
||||
readonly showStatusMenu = signal(false);
|
||||
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
|
||||
@@ -75,6 +85,7 @@ export class ProfileCardComponent {
|
||||
];
|
||||
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly users = this.store.selectSignal(selectUsersEntities);
|
||||
private readonly userStatus = inject(UserStatusService);
|
||||
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
@if (presentation(); as value) {
|
||||
@if (value.kind === 'url') {
|
||||
<span
|
||||
class="inline-flex h-[1em] w-[1em] shrink-0 items-center justify-center leading-none text-foreground/55"
|
||||
[attr.title]="value.url"
|
||||
[attr.aria-label]="'Registered on ' + value.url"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="block h-[1em] w-[1em]"
|
||||
/>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="shrink-0 leading-none text-foreground/55">{{ value.display }}</span>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideGlobe } from '@ng-icons/lucide';
|
||||
import { presentSignalServerTag } from '../../../domains/server-directory/domain/logic/signal-server-tag.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-signal-server-tag',
|
||||
standalone: true,
|
||||
imports: [NgIcon],
|
||||
viewProviders: [provideIcons({ lucideGlobe })],
|
||||
templateUrl: './profile-signal-server-tag.component.html'
|
||||
})
|
||||
export class ProfileSignalServerTagComponent {
|
||||
readonly tag = input<string | undefined>();
|
||||
|
||||
readonly presentation = computed(() => {
|
||||
const value = this.tag()?.trim();
|
||||
|
||||
return value ? presentSignalServerTag(value) : undefined;
|
||||
});
|
||||
}
|
||||
@@ -6,16 +6,37 @@
|
||||
@for (card of placeholders(); track $index) {
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<app-skeleton width="2.5rem" height="2.5rem" rounded="md" />
|
||||
<app-skeleton
|
||||
width="2.5rem"
|
||||
height="2.5rem"
|
||||
rounded="md"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<app-skeleton width="60%" height="0.875rem" />
|
||||
<app-skeleton width="40%" height="0.625rem" />
|
||||
<app-skeleton
|
||||
width="60%"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<app-skeleton
|
||||
width="40%"
|
||||
height="0.625rem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<app-skeleton width="100%" height="0.75rem" />
|
||||
<app-skeleton width="80%" height="0.75rem" />
|
||||
<app-skeleton
|
||||
width="100%"
|
||||
height="0.75rem"
|
||||
/>
|
||||
<app-skeleton
|
||||
width="80%"
|
||||
height="0.75rem"
|
||||
/>
|
||||
<div class="flex justify-end pt-1">
|
||||
<app-skeleton width="5rem" height="2rem" rounded="md" [block]="false" />
|
||||
<app-skeleton
|
||||
width="5rem"
|
||||
height="2rem"
|
||||
rounded="md"
|
||||
[block]="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="flex flex-col gap-2" [attr.aria-busy]="true">
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
[attr.aria-busy]="true"
|
||||
>
|
||||
@for (row of placeholders(); track $index) {
|
||||
<div class="flex items-center gap-3">
|
||||
@if (showAvatar()) {
|
||||
@@ -9,9 +12,15 @@
|
||||
/>
|
||||
}
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<app-skeleton [width]="primaryWidth()" [height]="rowHeight()" />
|
||||
<app-skeleton
|
||||
[width]="primaryWidth()"
|
||||
[height]="rowHeight()"
|
||||
/>
|
||||
@if (showSubtitle()) {
|
||||
<app-skeleton width="40%" height="0.625rem" />
|
||||
<app-skeleton
|
||||
width="40%"
|
||||
height="0.625rem"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
<div class="flex flex-col gap-3 px-4 py-3" [attr.aria-busy]="true">
|
||||
<div
|
||||
class="flex flex-col gap-3 px-4 py-3"
|
||||
[attr.aria-busy]="true"
|
||||
>
|
||||
@for (row of placeholders(); track $index) {
|
||||
<div class="flex items-start gap-3">
|
||||
<app-skeleton width="2.25rem" height="2.25rem" rounded="full" />
|
||||
<app-skeleton
|
||||
width="2.25rem"
|
||||
height="2.25rem"
|
||||
rounded="full"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<app-skeleton width="6rem" height="0.75rem" />
|
||||
<app-skeleton width="3rem" height="0.625rem" />
|
||||
<app-skeleton
|
||||
width="6rem"
|
||||
height="0.75rem"
|
||||
/>
|
||||
<app-skeleton
|
||||
width="3rem"
|
||||
height="0.625rem"
|
||||
/>
|
||||
</div>
|
||||
<app-skeleton width="85%" height="0.75rem" />
|
||||
<app-skeleton
|
||||
width="85%"
|
||||
height="0.75rem"
|
||||
/>
|
||||
@if ($index % 3 !== 2) {
|
||||
<app-skeleton width="55%" height="0.75rem" />
|
||||
<app-skeleton
|
||||
width="55%"
|
||||
height="0.75rem"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,7 @@
|
||||
[appVirtualRowMeasure]="virtualizer"
|
||||
[virtualRowIndex]="virtualRow.index"
|
||||
>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
itemTemplate || null;
|
||||
context: { $implicit: items()[virtualRow.index], index: virtualRow.index }
|
||||
"
|
||||
/>
|
||||
<ng-container *ngTemplateOutlet="itemTemplate || null; context: { $implicit: items()[virtualRow.index], index: virtualRow.index }" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -375,7 +375,8 @@ export class RoomSignalingConnection {
|
||||
this.webrtc.setCurrentServer(room.id);
|
||||
this.webrtc.identify(oderId, displayName, wsUrl, {
|
||||
description,
|
||||
profileUpdatedAt
|
||||
profileUpdatedAt,
|
||||
homeSignalServerUrl: user?.homeSignalServerUrl
|
||||
});
|
||||
|
||||
for (const backgroundRoom of backgroundRooms) {
|
||||
|
||||
@@ -126,6 +126,7 @@ export class RoomStateSyncEffects {
|
||||
...buildKnownUserExtras(room, user.oderId),
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
homeSignalServerUrl: user.homeSignalServerUrl,
|
||||
presenceServerIds: [signalingMessage.serverId],
|
||||
...(user.status ? { status: user.status } : {})
|
||||
})
|
||||
@@ -157,6 +158,7 @@ export class RoomStateSyncEffects {
|
||||
displayName: signalingMessage.displayName,
|
||||
description: signalingMessage.description,
|
||||
profileUpdatedAt: signalingMessage.profileUpdatedAt,
|
||||
homeSignalServerUrl: signalingMessage.homeSignalServerUrl,
|
||||
status: signalingMessage.status
|
||||
};
|
||||
const actions: Action[] = [
|
||||
@@ -165,6 +167,7 @@ export class RoomStateSyncEffects {
|
||||
...buildKnownUserExtras(room, joinedUser.oderId),
|
||||
description: joinedUser.description,
|
||||
profileUpdatedAt: joinedUser.profileUpdatedAt,
|
||||
homeSignalServerUrl: joinedUser.homeSignalServerUrl,
|
||||
presenceServerIds: [signalingMessage.serverId]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,6 +149,26 @@ export class RoomsEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/** Re-joins saved rooms after the signaling socket reconnects so presence is restored. */
|
||||
resyncRoomsOnSignalingReconnect$ = createEffect(
|
||||
() =>
|
||||
this.webrtc.signalingReconnected$.pipe(
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectSavedRooms)
|
||||
),
|
||||
tap(([
|
||||
, user,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
this.signalingConnection.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms, this.router.url);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Reconnects saved rooms so joined servers stay online while the app is running. */
|
||||
keepSavedRoomsConnected$ = createEffect(
|
||||
() =>
|
||||
|
||||
@@ -208,7 +208,14 @@ export interface RoomPresenceSignalingMessage {
|
||||
reason?: string;
|
||||
serverId?: string;
|
||||
serverIds?: string[];
|
||||
users?: { oderId: string; displayName: string; description?: string; profileUpdatedAt?: number; status?: string }[];
|
||||
users?: {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
status?: string;
|
||||
}[];
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
@@ -216,5 +223,6 @@ export interface RoomPresenceSignalingMessage {
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
profileUpdatedAt?: number;
|
||||
homeSignalServerUrl?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -489,7 +489,8 @@ export class UsersEffects {
|
||||
|
||||
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user), undefined, {
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
homeSignalServerUrl: user.homeSignalServerUrl
|
||||
});
|
||||
})
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user