From bf4e6891d157ab252dec96804251ae6c63c8429c Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 5 Jun 2026 06:16:02 +0200 Subject: [PATCH] feat: signal server tag --- agents-docs/FEATURES.md | 1 + agents-docs/LESSONS.md | 14 + agents-docs/features/signal-server-tag.md | 22 + electron/cqrs/commands/handlers/saveUser.ts | 3 +- electron/cqrs/mappers.ts | 3 +- electron/cqrs/types.ts | 1 + electron/entities/UserEntity.ts | 3 + .../1000000000012-AddHomeSignalServerUrl.ts | 13 + server/README.md | 2 +- server/src/config/signal-server-tag.ts | 33 + server/src/config/variables.ts | 20 + server/src/routes/health.ts | 5 +- server/src/websocket/handler-status.spec.ts | 38 +- server/src/websocket/handler.ts | 61 +- server/src/websocket/types.ts | 2 + toju-app/src/app/app.html | 20 +- .../logic/auth-navigation.rules.spec.ts | 39 + .../domain/logic/auth-navigation.rules.ts | 45 + .../feature/login/login.component.ts | 26 +- .../feature/register/register.component.ts | 26 +- .../chat-message-item.component.html | 1075 +++++++++-------- .../chat-message-list.component.html | 4 +- .../klipy-gif-picker.component.html | 9 +- .../custom-emoji-picker.component.html | 8 +- .../incoming-call-modal.component.html | 5 +- .../plugin-store/plugin-store.component.html | 4 +- .../services/server-directory.service.ts | 8 +- .../services/server-endpoint-state.service.ts | 4 +- .../logic/signal-server-tag.rules.spec.ts | 80 ++ .../domain/logic/signal-server-tag.rules.ts | 52 + .../domain/models/server-directory.model.ts | 4 + .../server-browser.component.html | 4 +- .../server-browser.component.ts | 3 +- .../server-endpoint-health.service.ts | 5 + .../dashboard/dashboard.component.html | 726 +++++------ ...ivate-call-participant-card.component.html | 6 +- .../direct-call/private-call.component.html | 360 +++--- ...voice-workspace-stream-tile.component.html | 4 +- .../general-settings.component.html | 5 +- .../browser-database-schema.spec.ts | 121 ++ .../persistence/browser-database-schema.ts | 67 + .../persistence/browser-database.service.ts | 75 +- .../src/app/infrastructure/realtime/README.md | 2 +- .../connection/create-peer-connection.ts | 4 + .../realtime/realtime-session.service.ts | 17 +- .../realtime/realtime.constants.spec.ts | 22 + .../realtime/realtime.constants.ts | 18 + .../infrastructure/realtime/realtime.types.ts | 2 + .../signaling-endpoint-health.rules.spec.ts | 54 + .../signaling-endpoint-health.rules.ts | 92 ++ .../signaling/signaling-transport-handler.ts | 33 +- .../signaling/signaling.manager.spec.ts | 345 ++++++ .../realtime/signaling/signaling.manager.ts | 275 ++++- toju-app/src/app/shared-kernel/user.models.ts | 2 + .../profile-card-mobile.component.html | 7 +- .../profile-card-mobile.component.ts | 11 + .../profile-card/profile-card.component.html | 10 +- .../profile-card/profile-card.component.ts | 11 + .../profile-signal-server-tag.component.html | 16 + .../profile-signal-server-tag.component.ts | 25 + .../skeleton/skeleton-card.component.html | 33 +- .../skeleton/skeleton-list.component.html | 15 +- .../skeleton/skeleton-message.component.html | 31 +- .../virtual-list/virtual-list.component.html | 7 +- .../store/rooms/room-signaling-connection.ts | 3 +- .../store/rooms/room-state-sync.effects.ts | 3 + toju-app/src/app/store/rooms/rooms.effects.ts | 20 + toju-app/src/app/store/rooms/rooms.helpers.ts | 10 +- toju-app/src/app/store/users/users.effects.ts | 3 +- 69 files changed, 2808 insertions(+), 1269 deletions(-) create mode 100644 agents-docs/features/signal-server-tag.md create mode 100644 electron/migrations/1000000000012-AddHomeSignalServerUrl.ts create mode 100644 server/src/config/signal-server-tag.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.spec.ts create mode 100644 toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts create mode 100644 toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts create mode 100644 toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.ts create mode 100644 toju-app/src/app/infrastructure/persistence/browser-database-schema.spec.ts create mode 100644 toju-app/src/app/infrastructure/persistence/browser-database-schema.ts create mode 100644 toju-app/src/app/infrastructure/realtime/realtime.constants.spec.ts create mode 100644 toju-app/src/app/infrastructure/realtime/signaling/signaling-endpoint-health.rules.spec.ts create mode 100644 toju-app/src/app/infrastructure/realtime/signaling/signaling-endpoint-health.rules.ts create mode 100644 toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts create mode 100644 toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.html create mode 100644 toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.ts diff --git a/agents-docs/FEATURES.md b/agents-docs/FEATURES.md index 28de64a..b4cf167 100644 --- a/agents-docs/FEATURES.md +++ b/agents-docs/FEATURES.md @@ -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//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. diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index d6ec119..ca2f51e 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -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)`. diff --git a/agents-docs/features/signal-server-tag.md b/agents-docs/features/signal-server-tag.md new file mode 100644 index 0000000..f9a59ea --- /dev/null +++ b/agents-docs/features/signal-server-tag.md @@ -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. diff --git a/electron/cqrs/commands/handlers/saveUser.ts b/electron/cqrs/commands/handlers/saveUser.ts index dc04c4b..e7df3c6 100644 --- a/electron/cqrs/commands/handlers/saveUser.ts +++ b/electron/cqrs/commands/handlers/saveUser.ts @@ -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); diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index 7dd4d9d..8ceaccf 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -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 }; } diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index cf09a52..eb5238d 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -130,6 +130,7 @@ export interface UserPayload { isRoomOwner?: boolean; voiceState?: unknown; screenShareState?: unknown; + homeSignalServerUrl?: string; } export interface RoomPayload { diff --git a/electron/entities/UserEntity.ts b/electron/entities/UserEntity.ts index 22e73b2..572fb6b 100644 --- a/electron/entities/UserEntity.ts +++ b/electron/entities/UserEntity.ts @@ -62,4 +62,7 @@ export class UserEntity { @Column('text', { nullable: true }) screenShareState!: string | null; + + @Column('text', { nullable: true }) + homeSignalServerUrl!: string | null; } diff --git a/electron/migrations/1000000000012-AddHomeSignalServerUrl.ts b/electron/migrations/1000000000012-AddHomeSignalServerUrl.ts new file mode 100644 index 0000000..9289ee2 --- /dev/null +++ b/electron/migrations/1000000000012-AddHomeSignalServerUrl.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHomeSignalServerUrl1000000000012 implements MigrationInterface { + name = 'AddHomeSignalServerUrl1000000000012'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "homeSignalServerUrl" TEXT`); + } + + public async down(queryRunner: QueryRunner): Promise { + // SQLite column removal requires table rebuilds. Keep rollback no-op. + } +} diff --git a/server/README.md b/server/README.md index 1ae007f..2c038ce 100644 --- a/server/README.md +++ b/server/README.md @@ -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. diff --git a/server/src/config/signal-server-tag.ts b/server/src/config/signal-server-tag.ts new file mode 100644 index 0000000..f2d5f85 --- /dev/null +++ b/server/src/config/signal-server-tag.ts @@ -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; +} diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index 401ebb5..843731a 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -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'; } diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 17f95a8..8c271ba 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -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() }); }); diff --git a/server/src/websocket/handler-status.spec.ts b/server/src/websocket/handler-status.spec.ts index d27603b..4b4dc16 100644 --- a/server/src/websocket/handler-status.spec.ts +++ b/server/src/websocket/handler-status.spec.ts @@ -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'); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index fb0a6d6..186b93d 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -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': diff --git a/server/src/websocket/types.ts b/server/src/websocket/types.ts index d6494ef..2f1fa0d 100644 --- a/server/src/websocket/types.ts +++ b/server/src/websocket/types.ts @@ -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. */ diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 48a75b0..64666a8 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -24,7 +24,7 @@ [ngStyle]="isMobile() ? null : appWorkspaceShellStyles()" > @if (!isMobile()) { - + }
@@ -94,16 +94,14 @@
@if (isMobile() && directCalls.mobileOverlaySession(); as call) { -
- -
- } - - @if (isThemeStudioFullscreen()) { +
+ +
+ } @if (isThemeStudioFullscreen()) {
{ + 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.' + }); + }); +}); diff --git a/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts new file mode 100644 index 0000000..01651ef --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts @@ -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 { + 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 + }; + }) + ); +} diff --git a/toju-app/src/app/domains/authentication/feature/login/login.component.ts b/toju-app/src/app/domains/authentication/feature/login/login.component.ts index c65abe8..641f045 100644 --- a/toju-app/src/app/domains/authentication/feature/login/login.component.ts +++ b/toju-app/src/app/domains/authentication/feature/login/login.component.ts @@ -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(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'); diff --git a/toju-app/src/app/domains/authentication/feature/register/register.component.ts b/toju-app/src/app/domains/authentication/feature/register/register.component.ts index 4df3b07..f16181b 100644 --- a/toju-app/src/app/domains/authentication/feature/register/register.component.ts +++ b/toju-app/src/app/domains/authentication/feature/register/register.component.ts @@ -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(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'); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index c0267f6..8c27192 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -16,369 +16,402 @@
} @else { -
- -
- -
- @if (msg.replyToId) { - @let reply = repliedMessage(); -
-
- - @if (reply) { - {{ reply.senderName }} - {{ reply.isDeleted ? deletedMessageContent : formatMessagePreview(reply.content) }} - } @else { - Original message not found - } -
- } - -
- {{ msg.senderName }} - {{ formatTimestamp(msg.timestamp) }} - @if (msg.editedAt && !msg.isDeleted) { - (edited) - } +
+
- @if (isEditing()) { -
- -
- - -
-
- } @else { - @if (msg.isDeleted) { -
{{ deletedMessageContent }}
- } @else { - @if (!pluginEmbedToken()) { - @if (requiresRichMarkdown(msg.content)) { - @defer { -
- -
- } @placeholder { -
{{ formatMessagePreview(msg.content) }}
- } +
+ @if (msg.replyToId) { + @let reply = repliedMessage(); +
+
+ + @if (reply) { + {{ reply.senderName }} + {{ reply.isDeleted ? deletedMessageContent : formatMessagePreview(reply.content) }} } @else { -
{{ msg.content }}
+ Original message not found } - } +
+ } - @if (missingPluginEmbed(); as missingEmbed) { -
- Required plugin is not installed to view this content, visit the +
+ {{ msg.senderName }} + {{ formatTimestamp(msg.timestamp) }} + @if (msg.editedAt && !msg.isDeleted) { + (edited) + } +
+ + @if (isEditing()) { +
+ +
. -
- } - - @if (msg.linkMetadata?.length) { - @for (meta of msg.linkMetadata; track meta.url) { - @if (shouldShowLinkEmbed(meta.url)) { - + + +
+
+ } @else { + @if (msg.isDeleted) { +
{{ deletedMessageContent }}
+ } @else { + @if (!pluginEmbedToken()) { + @if (requiresRichMarkdown(msg.content)) { + @defer { +
+ +
+ } @placeholder { +
{{ formatMessagePreview(msg.content) }}
+ } + } @else { +
{{ msg.content }}
} } - } - @if (pluginEmbeds().length > 0) { -
- @for (embed of pluginEmbeds(); track embed.id) { -
-
- {{ embed.contribution.embedType }} - {{ embed.pluginId }} -
- -
+ @if (missingPluginEmbed(); as missingEmbed) { +
+ Required plugin is not installed to view this content, visit the + . +
+ } + + @if (msg.linkMetadata?.length) { + @for (meta of msg.linkMetadata; track meta.url) { + @if (shouldShowLinkEmbed(meta.url)) { + + } } -
- } + } - @if (attachmentsList.length > 0) { -
- @for (att of attachmentsList; track att.id) { - @if (att.isImage) { - @if (att.available && att.objectUrl) { -
- -
-
- - -
+ @if (pluginEmbeds().length > 0) { +
+ @for (embed of pluginEmbeds(); track embed.id) { +
+
+ {{ embed.contribution.embedType }} + {{ embed.pluginId }}
- } @else if ((att.receivedBytes || 0) > 0) { -
-
-
- -
-
-
{{ att.filename }}
-
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
-
-
{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%
-
-
-
-
-
- } @else { -
-
-
- -
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
-
- {{ att.requestError || 'Waiting for image source...' }} -
-
-
-
+ } +
+ } + + @if (attachmentsList.length > 0) { +
+ @for (att of attachmentsList; track att.id) { + @if (att.isImage) { + @if (att.available && att.objectUrl) { +
- Retry - -
- } - } @else if (att.isVideo || att.isAudio) { - @if (att.available && att.objectUrl) { - @if (att.isVideo) { - - } @else { - - } - } @else if ((att.receivedBytes || 0) > 0) { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
-
- -
-
-
-
-
- {{ att.progressPercent | number: '1.0-0' }}% - @if (att.speedBps) { - {{ formatSpeed(att.speedBps) }} - } -
-
- } @else { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
-
+
+
+ + +
+
+ } @else if ((att.receivedBytes || 0) > 0) { +
+
+
+ +
+
+
{{ att.filename }}
+
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
+
+
{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%
+
+
+
+
+
+ } @else { +
+
+
+ +
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
+ {{ att.requestError || 'Waiting for image source...' }} +
-
- } - } @else { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
+ } + } @else if (att.isVideo || att.isAudio) { + @if (att.available && att.objectUrl) { + @if (att.isVideo) { + + } @else { + + } + } @else if ((att.receivedBytes || 0) > 0) { +
+
+
+
{{ att.filename }}
+
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
+
+ +
+
+
+
+
+ {{ att.progressPercent | number: '1.0-0' }}% + @if (att.speedBps) { + {{ formatSpeed(att.speedBps) }} + } +
-
- @if (!att.isUploader) { - @if (!att.available) { -
-
+ } @else { +
+
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
+ {{ att.mediaStatusText }}
-
- {{ att.progressPercent | number: '1.0-0' }}% - @if (att.speedBps) { - • {{ formatSpeed(att.speedBps) }} +
+ +
+
+ } + } @else { +
+
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
+
+ @if (!att.isUploader) { + @if (!att.available) { +
+
+
+
+ {{ att.progressPercent | number: '1.0-0' }}% + @if (att.speedBps) { + • {{ formatSpeed(att.speedBps) }} + } +
+ @if (!(att.receivedBytes || 0)) { + + } @else { + } -
- @if (!(att.receivedBytes || 0)) { - } @else { + @if (att.canOpenExternally) { + + } + @if (att.canUseExperimentalPlayer) { + + } } } @else { +
Shared from your device
@if (att.canOpenExternally) { } - } @else { -
Shared from your device
- @if (att.canOpenExternally) { - - } - @if (att.canUseExperimentalPlayer) { - - } - } +
+ @if (!att.available && att.requestError) { +
+ {{ att.requestError }} +
+ }
- @if (!att.available && att.requestError) { -
- {{ att.requestError }} -
- } -
- @if (att.experimentalPlayerActive && att.objectUrl) { - @defer { - - } @loading { -
- Loading experimental player... -
+ @if (att.experimentalPlayerActive && att.objectUrl) { + @defer { + + } @loading { +
+ Loading experimental player... +
+ } } } } - } -
+
+ } } } - } - @if (!msg.isDeleted && msg.reactions.length > 0) { -
- @for (reaction of getGroupedReactions(); track reaction.emoji) { - - } -
- } -
+ @if (!msg.isDeleted && msg.reactions.length > 0) { +
+ @for (reaction of getGroupedReactions(); track reaction.emoji) { + + } +
+ } +
- @if (!msg.isDeleted && !isMobile()) { -
-
- - - @if (showEmojiPicker()) { -
- -
- } -
- - +
+ + + @if (showEmojiPicker()) { +
+ +
+ } +
- @if (isOwnMessage()) { - } - - @if (isOwnMessage() || isAdmin()) { - - } -
- } - - - -
-
-

React

-
- @for (entry of emojiShortcuts(); track entry.key) { - - } -
-
- -
- - - - @if (isOwnMessage()) { } @if (isOwnMessage() || isAdmin()) { }
-
-
+ } -
+ + +
+
+

React

+
+ @for (entry of emojiShortcuts(); track entry.key) { + + } +
+
+ +
+ + + + + + @if (isOwnMessage()) { + + } + + @if (isOwnMessage() || isAdmin()) { + + } +
+
+
+
} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html index 414d31b..f181bc7 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html @@ -14,9 +14,7 @@ @if (refreshLoading()) {
-
- Loading... -
+
Loading...
} diff --git a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html index fe4b5fd..ab75970 100644 --- a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html +++ b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html @@ -87,9 +87,12 @@ - - +
+
+
+

+ @if (currentUser()) { + Welcome back, {{ currentUser()!.displayName || 'there' }} + } @else { + Welcome to MetoYou } - -

- } -
+ +

Find people, discover servers, or start your own community.

+ - @if (isSearchMode()) { -
- @if (inviteResult(); as invite) { -
-

Invite

+
+
+ + + +
+ + @if (!isSearchMode() && recentSearches().length > 0) { +
+ Recent: + @for (term of recentSearches(); track term) { + + + + + }
} +
- @if (topServerResults().length > 0) { -
-
-

Servers

- View all + @if (inviteResult(); as invite) { +
+

Invite

+
-
- @for (server of topServerResults(); track server.id) { - - } +
+
+

Open invite

+

{{ invite }}

+
+ +
-
+ } + + @if (topServerResults().length > 0) { +
+
+

Servers

+ View all +
+
+ @for (server of topServerResults(); track server.id) { + + } +
+
+ } + + @if (topPeopleResults().length > 0) { +
+
+

People

+ View all +
+
+ @for (person of topPeopleResults(); track person.id) { + + +
+

{{ personLabel(person) }}

+

{{ isOnline(person) ? 'Online' : 'Offline' }}

+
+ +
+ } +
+
+ } + + @if (hasNoQuickResults() && !isSearching()) { +
+ No people, servers, or invites match + {{ searchQuery() }}. +
+ } +
+ } @else { + +
+ +
+ +
+
+

Find People

+

Connect with friends.

+
+ +
+ + +
+ +
+
+

Find Servers

+

Browse communities.

+
+ +
+ + +
+ +
+
+

Create Server

+

Start your own.

+
+ +
+
+ + @if (isNewUser()) { +
+
+ +
+

Get started

+

+ You have not joined any servers yet. Find a community to join, or create your own to invite friends. +

+
} - @if (topPeopleResults().length > 0) { -
-
-

People

+ +
+
+
+

People you might know

View allSee all
-
- @for (person of topPeopleResults(); track person.id) { - - - + +
+
+

Popular Servers

+ See all
+ @if (popularServers().length > 0) { +
+ @for (server of popularServers(); track server.id) { +
+
+ @if (server.icon) { + + } @else { + {{ serverInitial(server) }} + } +
+
+

{{ server.name }}

+

{{ serverMetaLabel(server) }}

+
+ +
+ } +
+ } @else { +

No popular servers right now.

+ }
- } - - @if (hasNoQuickResults() && !isSearching()) { -
- No people, servers, or invites match - {{ searchQuery() }}. -
- } -
- } @else { - -
- -
- -
-
-

Find People

-

Connect with friends.

-
- -
- - -
- -
-
-

Find Servers

-

Browse communities.

-
- -
- - -
- -
-
-

Create Server

-

Start your own.

-
- -
-
- - @if (isNewUser()) { -
-
- -
-

Get started

-

- You have not joined any servers yet. Find a community to join, or create your own to invite friends. -

- } - -
-
-
-

People you might know

- See all -
- @if (peopleYouMightKnow().length > 0) { -
- @for (person of peopleYouMightKnow(); track person.id) { -
+ + @if (friends().length > 0) { +
+
+

Your Friends

+ Manage +
+
+ @for (friend of friends(); track friend.id) { +
-

{{ personLabel(person) }}

-

{{ isOnline(person) ? 'Online' : 'Offline' }}

+

{{ personLabel(friend) }}

+

{{ isOnline(friend) ? 'Online' : 'Offline' }}

- +
}
- } @else { -

No people to suggest yet.

- } -
+
+ } -
-
-

Popular Servers

- See all -
- @if (popularServers().length > 0) { -
- @for (server of popularServers(); track server.id) { -
-
- @if (server.icon) { + + @if (recentlyActiveServers().length > 0) { +
+

Recently Active Servers

+
+ @for (room of recentlyActiveServers(); track room.id) { + -
+

{{ room.name }}

+

{{ room.userCount }} members

+ }
- } @else { -

No popular servers right now.

- } -
- - - - @if (friends().length > 0) { -
-
-

Your Friends

- Manage -
-
- @for (friend of friends(); track friend.id) { -
- -
-

{{ personLabel(friend) }}

-

{{ isOnline(friend) ? 'Online' : 'Offline' }}

-
- -
- } -
-
+ + } } - - - @if (recentlyActiveServers().length > 0) { -
-

Recently Active Servers

-
- @for (room of recentlyActiveServers(); track room.id) { - - } -
-
- } - } +
-
@if (isMobile()) { diff --git a/toju-app/src/app/features/direct-call/private-call-participant-card.component.html b/toju-app/src/app/features/direct-call/private-call-participant-card.component.html index 03f4e8b..168541e 100644 --- a/toju-app/src/app/features/direct-call/private-call-participant-card.component.html +++ b/toju-app/src/app/features/direct-call/private-call-participant-card.component.html @@ -1,6 +1,10 @@
-
-
-
-
-
- +
+
+
+
+
+ +
+ +
+

Private Call

+

+ @if (session()) { + {{ participantUsers().length }} participants + } @else { + Call not found + } +

+
-
-

Private Call

-

- @if (session()) { - {{ participantUsers().length }} participants - } @else { - Call not found + @if (session()) { +

+ @if (isMobile()) { + } -

-
-
- - @if (session()) { -
- @if (isMobile()) { + - } - - -
- } -
+
+ } +
- @if (session()) { -
-
- @if (activeShares().length > 0) { - @if (focusedShare()) { - @if (hasMultipleShares()) { -
- -
- } - - - } @else if (hasMultipleShares()) { -
- @for (share of activeShares(); track share.id) { -
- + @if (session()) { +
+
+ @if (activeShares().length > 0) { + @if (focusedShare()) { + @if (hasMultipleShares()) { +
+
} + + + } @else if (hasMultipleShares()) { +
+ @for (share of activeShares(); track share.id) { +
+ +
+ } +
+ } + } @else { +
+
+ @for (user of participantUsers(); track trackUserKey($index, user)) { + + } +
} - } @else { -
-
+
+ + @if (activeShares().length > 0) { +
+
@for (user of participantUsers(); track trackUserKey($index, user)) { } + + @if (hasMultipleShares()) { + @for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) { +
+
+ +
+
+ {{ streamLabel(share) }} +
+
+ } + }
} -
- @if (activeShares().length > 0) { -
-
- @for (user of participantUsers(); track trackUserKey($index, user)) { - - } - - @if (hasMultipleShares()) { - @for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) { -
-
- -
-
- {{ streamLabel(share) }} -
-
- } - } -
+
+
- } - -
-
-
- } @else { -
No active call for this route.
- } -
+ } @else { +
No active call for this route.
+ } + - -
+ + @if (showScreenShareQualityDialog()) { diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.html b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.html index 120842c..23bfbe9 100644 --- a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.html +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.html @@ -124,7 +124,9 @@ @if (immersive() && item().kind === 'screen' && !isFullscreen()) {
-
+
@if (canControlStreamAudio()) {
} -

{{ profileUser.username }}

+

+ {{ profileUser.username }} + +

@if (profileUser.gameActivity; as activity) {

} @else {

{{ profileUser.displayName }}

-

{{ profileUser.username }}

+

+ {{ profileUser.username }} + +

@if (profileUser.gameActivity; as activity) {
diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts index ac74e5b..9ff37b7 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts @@ -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); diff --git a/toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.html b/toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.html new file mode 100644 index 0000000..fbc4e3e --- /dev/null +++ b/toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.html @@ -0,0 +1,16 @@ +@if (presentation(); as value) { + @if (value.kind === 'url') { + + + + } @else { + {{ value.display }} + } +} diff --git a/toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.ts b/toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.ts new file mode 100644 index 0000000..a0e51e5 --- /dev/null +++ b/toju-app/src/app/shared/components/profile-card/profile-signal-server-tag.component.ts @@ -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(); + + readonly presentation = computed(() => { + const value = this.tag()?.trim(); + + return value ? presentSignalServerTag(value) : undefined; + }); +} diff --git a/toju-app/src/app/shared/components/skeleton/skeleton-card.component.html b/toju-app/src/app/shared/components/skeleton/skeleton-card.component.html index c73d2f7..9496336 100644 --- a/toju-app/src/app/shared/components/skeleton/skeleton-card.component.html +++ b/toju-app/src/app/shared/components/skeleton/skeleton-card.component.html @@ -6,16 +6,37 @@ @for (card of placeholders(); track $index) {
- +
- - + +
- - + +
- +
} diff --git a/toju-app/src/app/shared/components/skeleton/skeleton-list.component.html b/toju-app/src/app/shared/components/skeleton/skeleton-list.component.html index 2a1cf08..83a5e29 100644 --- a/toju-app/src/app/shared/components/skeleton/skeleton-list.component.html +++ b/toju-app/src/app/shared/components/skeleton/skeleton-list.component.html @@ -1,4 +1,7 @@ -
+
@for (row of placeholders(); track $index) {
@if (showAvatar()) { @@ -9,9 +12,15 @@ /> }
- + @if (showSubtitle()) { - + }
diff --git a/toju-app/src/app/shared/components/skeleton/skeleton-message.component.html b/toju-app/src/app/shared/components/skeleton/skeleton-message.component.html index e0ba6de..218a024 100644 --- a/toju-app/src/app/shared/components/skeleton/skeleton-message.component.html +++ b/toju-app/src/app/shared/components/skeleton/skeleton-message.component.html @@ -1,15 +1,34 @@ -
+
@for (row of placeholders(); track $index) {
- +
- - + +
- + @if ($index % 3 !== 2) { - + }
diff --git a/toju-app/src/app/shared/components/virtual-list/virtual-list.component.html b/toju-app/src/app/shared/components/virtual-list/virtual-list.component.html index fa61639..e2332fc 100644 --- a/toju-app/src/app/shared/components/virtual-list/virtual-list.component.html +++ b/toju-app/src/app/shared/components/virtual-list/virtual-list.component.html @@ -15,12 +15,7 @@ [appVirtualRowMeasure]="virtualizer" [virtualRowIndex]="virtualRow.index" > - +
}
diff --git a/toju-app/src/app/store/rooms/room-signaling-connection.ts b/toju-app/src/app/store/rooms/room-signaling-connection.ts index 113cf5b..ac23f24 100644 --- a/toju-app/src/app/store/rooms/room-signaling-connection.ts +++ b/toju-app/src/app/store/rooms/room-signaling-connection.ts @@ -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) { diff --git a/toju-app/src/app/store/rooms/room-state-sync.effects.ts b/toju-app/src/app/store/rooms/room-state-sync.effects.ts index d020806..5b71359 100644 --- a/toju-app/src/app/store/rooms/room-state-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-state-sync.effects.ts @@ -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] }) }) diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index 5ebf515..d1e9796 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -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( () => diff --git a/toju-app/src/app/store/rooms/rooms.helpers.ts b/toju-app/src/app/store/rooms/rooms.helpers.ts index c15fe81..0b8c1a6 100644 --- a/toju-app/src/app/store/rooms/rooms.helpers.ts +++ b/toju-app/src/app/store/rooms/rooms.helpers.ts @@ -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; } diff --git a/toju-app/src/app/store/users/users.effects.ts b/toju-app/src/app/store/users/users.effects.ts index b930622..9836eb9 100644 --- a/toju-app/src/app/store/users/users.effects.ts +++ b/toju-app/src/app/store/users/users.effects.ts @@ -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 }); }) ),