diff --git a/e2e/helpers/start-test-server.js b/e2e/helpers/start-test-server.js index 4adcea8..a7c2e0e 100644 --- a/e2e/helpers/start-test-server.js +++ b/e2e/helpers/start-test-server.js @@ -16,6 +16,7 @@ const TEST_PORT = process.env.TEST_SERVER_PORT || '3099'; const SERVER_DIR = join(__dirname, '..', '..', 'server'); const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts'); const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json'); +const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js'); // ── Create isolated temp data directory ────────────────────────────── const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-')); @@ -43,8 +44,8 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`); // Module resolution (require/import) uses __dirname, so server source // and node_modules are found from the real server/ directory. const child = spawn( - 'npx', - ['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY], + process.execPath, + [TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY], { cwd: tmpDir, env: { @@ -55,7 +56,6 @@ const child = spawn( DB_SYNCHRONIZE: 'true', }, stdio: 'inherit', - shell: true, } ); diff --git a/e2e/helpers/webrtc-helpers.ts b/e2e/helpers/webrtc-helpers.ts index 8d9428b..40028c8 100644 --- a/e2e/helpers/webrtc-helpers.ts +++ b/e2e/helpers/webrtc-helpers.ts @@ -11,9 +11,15 @@ import { type Page } from '@playwright/test'; export async function installWebRTCTracking(page: Page): Promise { await page.addInitScript(() => { const connections: RTCPeerConnection[] = []; + const syntheticMediaResources: { + audioCtx: AudioContext; + source?: AudioScheduledSourceNode; + drawIntervalId?: number; + }[] = []; (window as any).__rtcConnections = connections; (window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; + (window as any).__rtcSyntheticMediaResources = syntheticMediaResources; const OriginalRTCPeerConnection = window.RTCPeerConnection; @@ -55,18 +61,37 @@ export async function installWebRTCTracking(page: Page): Promise { // Get the original stream (may include video) const originalStream = await origGetUserMedia(constraints); const audioCtx = new AudioContext(); - const oscillator = audioCtx.createOscillator(); + const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate); + const noiseData = noiseBuffer.getChannelData(0); - oscillator.frequency.value = 440; + for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) { + noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18; + } + + const source = audioCtx.createBufferSource(); + const gain = audioCtx.createGain(); + + source.buffer = noiseBuffer; + source.loop = true; + gain.gain.value = 0.12; const dest = audioCtx.createMediaStreamDestination(); - oscillator.connect(dest); - oscillator.start(); + source.connect(gain); + gain.connect(dest); + source.start(); + + if (audioCtx.state === 'suspended') { + try { + await audioCtx.resume(); + } catch {} + } const synthAudioTrack = dest.stream.getAudioTracks()[0]; const resultStream = new MediaStream(); + syntheticMediaResources.push({ audioCtx, source }); + resultStream.addTrack(synthAudioTrack); // Keep any video tracks from the original stream @@ -79,6 +104,14 @@ export async function installWebRTCTracking(page: Page): Promise { track.stop(); } + synthAudioTrack.addEventListener('ended', () => { + try { + source.stop(); + } catch {} + + void audioCtx.close().catch(() => {}); + }, { once: true }); + return resultStream; }; @@ -128,10 +161,32 @@ export async function installWebRTCTracking(page: Page): Promise { osc.connect(dest); osc.start(); + if (audioCtx.state === 'suspended') { + try { + await audioCtx.resume(); + } catch {} + } + const audioTrack = dest.stream.getAudioTracks()[0]; // Combine video + audio into one stream const resultStream = new MediaStream([videoTrack, audioTrack]); + syntheticMediaResources.push({ + audioCtx, + source: osc, + drawIntervalId: drawInterval as unknown as number + }); + + audioTrack.addEventListener('ended', () => { + clearInterval(drawInterval); + + try { + osc.stop(); + } catch {} + + void audioCtx.close().catch(() => {}); + }, { once: true }); + // Tag the stream so tests can identify it (resultStream as any).__isScreenShare = true; diff --git a/e2e/pages/register.page.ts b/e2e/pages/register.page.ts index 959845f..1887b47 100644 --- a/e2e/pages/register.page.ts +++ b/e2e/pages/register.page.ts @@ -29,11 +29,12 @@ export class RegisterPage { try { await expect(this.usernameInput).toBeVisible({ timeout: 10_000 }); } catch { - // Angular router may redirect to /login on first load; click through. - const registerLink = this.page.getByRole('link', { name: 'Register' }) - .or(this.page.getByText('Register')); + // Angular router may redirect to /login on first load; use the + // visible login-form action instead of broad text matching. + const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last(); - await registerLink.first().click(); + await expect(registerButton).toBeVisible({ timeout: 10_000 }); + await registerButton.click(); await expect(this.usernameInput).toBeVisible({ timeout: 30_000 }); } diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 88745ed..4280df3 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -36,6 +36,7 @@ import { } from '../update/desktop-updater'; import { consumePendingDeepLink } from '../app/deep-links'; import { synchronizeAutoStartSetting } from '../app/auto-start'; +import { getIdleState } from '../idle/idle-monitor'; import { getMainWindow, getWindowIconPath, @@ -557,16 +558,22 @@ export function setupSystemHandlers(): void { } switch (command) { - case 'cut': webContents.cut(); break; - case 'copy': webContents.copy(); break; - case 'paste': webContents.paste(); break; - case 'selectAll': webContents.selectAll(); break; + case 'cut': + webContents.cut(); + break; + case 'copy': + webContents.copy(); + break; + case 'paste': + webContents.paste(); + break; + case 'selectAll': + webContents.selectAll(); + break; } }); ipcMain.handle('get-idle-state', () => { - const { getIdleState } = require('../idle/idle-monitor') as typeof import('../idle/idle-monitor'); - return getIdleState(); }); } diff --git a/eslint.config.js b/eslint.config.js index c18212b..603053f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -123,7 +123,7 @@ module.exports = tseslint.config( 'complexity': ['warn',{ max:20 }], 'curly': 'off', 'eol-last': 'error', - 'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'], + 'id-denylist': ['warn','e','cb','i','c','any','string','String','Undefined','undefined','callback'], 'max-len': ['error',{ code:150, ignoreComments:true }], 'new-parens': 'error', 'newline-per-chained-call': 'error', @@ -172,7 +172,7 @@ module.exports = tseslint.config( // Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); } 'max-statements-per-line': ['error', { max: 1 }], // Prevent single-character identifiers for variables/params; do not check object property names - 'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }], + 'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_', 'x', 'y'] }], // Require blank lines around block-like statements (if, function, class, switch, try, etc.) 'padding-line-between-statements': [ 'error', diff --git a/server/src/index.ts b/server/src/index.ts index d3c4f52..d27d4a5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -59,6 +59,9 @@ function buildServer(app: ReturnType, serverProtocol: ServerHt return createHttpServer(app); } +let listeningServer: ReturnType | null = null; +let staleJoinRequestInterval: ReturnType | null = null; + async function bootstrap(): Promise { const variablesConfig = ensureVariablesConfig(); const serverProtocol = getServerProtocol(); @@ -86,10 +89,12 @@ async function bootstrap(): Promise { const app = createApp(); const server = buildServer(app, serverProtocol); + listeningServer = server; + setupWebSocket(server); // Periodically clean up stale join requests (older than 24 h) - setInterval(() => { + staleJoinRequestInterval = setInterval(() => { deleteStaleJoinRequests(24 * 60 * 60 * 1000) .catch(err => console.error('Failed to clean up stale join requests:', err)); }, 60 * 1000); @@ -127,8 +132,25 @@ async function gracefulShutdown(signal: string): Promise { shuttingDown = true; + if (staleJoinRequestInterval) { + clearInterval(staleJoinRequestInterval); + staleJoinRequestInterval = null; + } + console.log(`\n[Shutdown] ${signal} received - closing database…`); + if (listeningServer?.listening) { + try { + await new Promise((resolve) => { + listeningServer?.close(() => resolve()); + }); + } catch (err) { + console.error('[Shutdown] Error closing server:', err); + } + } + + listeningServer = null; + try { await destroyDatabase(); } catch (err) { diff --git a/server/src/routes/klipy.ts b/server/src/routes/klipy.ts index 179aab7..f095577 100644 --- a/server/src/routes/klipy.ts +++ b/server/src/routes/klipy.ts @@ -1,4 +1,3 @@ -/* eslint-disable complexity */ import { Router } from 'express'; import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables'; @@ -47,6 +46,11 @@ interface KlipyApiResponse { }; } +interface ResolvedGifMedia { + previewMeta: NormalizedMediaMeta | null; + sourceMeta: NormalizedMediaMeta; +} + function pickFirst(...values: (T | null | undefined)[]): T | undefined { for (const value of values) { if (value != null) @@ -130,33 +134,49 @@ function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext }; } +function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null { + const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd); + const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs); + const previewMeta = pickGifMeta(previewVariant); + const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta; + + if (!sourceMeta?.url) + return null; + + return { + previewMeta, + sourceMeta + }; +} + +function resolveGifSlug(gifItem: KlipyGifItem): string | undefined { + return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id); +} + function normalizeGifItem(item: unknown): NormalizedKlipyGif | null { if (!item || typeof item !== 'object') return null; const gifItem = item as KlipyGifItem; + const resolvedMedia = resolveGifMedia(gifItem.file); + const slug = resolveGifSlug(gifItem); if (gifItem.type === 'ad') return null; - const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd); - const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs); - const lowMeta = pickGifMeta(lowVariant); - const highMeta = pickGifMeta(highVariant); - const selectedMeta = highMeta ?? lowMeta; - const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id); - - if (!slug || !selectedMeta?.url) + if (!slug || !resolvedMedia) return null; + const { previewMeta, sourceMeta } = resolvedMedia; + return { id: slug, slug, title: sanitizeString(gifItem.title), - url: selectedMeta.url, - previewUrl: lowMeta?.url ?? selectedMeta.url, - width: selectedMeta.width ?? lowMeta?.width ?? 0, - height: selectedMeta.height ?? lowMeta?.height ?? 0 + url: sourceMeta.url, + previewUrl: previewMeta?.url ?? sourceMeta.url, + width: sourceMeta.width ?? previewMeta?.width ?? 0, + height: sourceMeta.height ?? previewMeta?.height ?? 0 }; } diff --git a/server/src/websocket/handler-status.spec.ts b/server/src/websocket/handler-status.spec.ts index c168fc1..71ff2c9 100644 --- a/server/src/websocket/handler-status.spec.ts +++ b/server/src/websocket/handler-status.spec.ts @@ -44,6 +44,19 @@ function createConnectedUser( return user; } +function getRequiredConnectedUser(connectionId: string): ConnectedUser { + const connectedUser = connectedUsers.get(connectionId); + + if (!connectedUser) + throw new Error(`Expected connected user for ${connectionId}`); + + return connectedUser; +} + +function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } { + return user.ws as unknown as { sentMessages: string[] }; +} + describe('server websocket handler - status_update', () => { beforeEach(() => { connectedUsers.clear(); @@ -68,27 +81,24 @@ describe('server websocket handler - status_update', () => { await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' }); - const ws2 = user2.ws as unknown as { sentMessages: string[] }; - const messages = ws2.sentMessages.map((m: string) => JSON.parse(m)); - const statusMsg = messages.find((m: { type: string }) => m.type === 'status_update'); + const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText)); + const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update'); expect(statusMsg).toBeDefined(); - expect(statusMsg.oderId).toBe('user-1'); - expect(statusMsg.status).toBe('busy'); + expect(statusMsg?.oderId).toBe('user-1'); + expect(statusMsg?.status).toBe('busy'); }); it('does not broadcast to users in different servers', async () => { createConnectedUser('conn-1', 'user-1'); const user2 = createConnectedUser('conn-2', 'user-2'); - connectedUsers.get('conn-1')!.serverIds.add('server-1'); + getRequiredConnectedUser('conn-1').serverIds.add('server-1'); user2.serverIds.add('server-2'); await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' }); - const ws2 = user2.ws as unknown as { sentMessages: string[] }; - - expect(ws2.sentMessages.length).toBe(0); + expect(getSentMessagesStore(user2).sentMessages.length).toBe(0); }); it('ignores invalid status values', async () => { @@ -134,22 +144,21 @@ describe('server websocket handler - status_update', () => { await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' }); // Clear sent messages - (user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0; + getSentMessagesStore(user2).sentMessages.length = 0; // Identify first (required for handler) await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' }); // user-2 joins server → should receive server_users with user-1's status - (user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0; + getSentMessagesStore(user2).sentMessages.length = 0; await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' }); - const ws2 = user2.ws as unknown as { sentMessages: string[] }; - const messages = ws2.sentMessages.map((m: string) => JSON.parse(m)); - const serverUsersMsg = messages.find((m: { type: string }) => m.type === 'server_users'); + const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText)); + const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users'); expect(serverUsersMsg).toBeDefined(); - const user1InList = serverUsersMsg.users.find((u: { oderId: string }) => u.oderId === 'user-1'); + const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1'); expect(user1InList?.status).toBe('away'); }); @@ -168,19 +177,18 @@ describe('server websocket handler - user_joined includes status', () => { user2.serverIds.add('server-1'); // Set user-1's status to busy before joining - connectedUsers.get('conn-1')!.status = 'busy'; + getRequiredConnectedUser('conn-1').status = 'busy'; // Identify user-1 await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' }); - (user2.ws as unknown as { sentMessages: string[] }).sentMessages.length = 0; + getSentMessagesStore(user2).sentMessages.length = 0; // user-1 joins server-1 await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' }); - const ws2 = user2.ws as unknown as { sentMessages: string[] }; - const messages = ws2.sentMessages.map((m: string) => JSON.parse(m)); - const joinMsg = messages.find((m: { type: string }) => m.type === 'user_joined'); + const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText)); + const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined'); // user_joined may or may not appear depending on whether it's a new identity membership // Since both are already in the server, it may not broadcast. Either way, verify no crash. diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 0e3197c..02bdbd0 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -34,13 +34,13 @@ import { ExternalLinkService } from './core/platform'; import { SettingsModalService } from './core/services/settings-modal.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { UserStatusService } from './core/services/user-status.service'; -import { ServersRailComponent } from './features/servers/servers-rail.component'; -import { TitleBarComponent } from './features/shell/title-bar.component'; +import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component'; +import { TitleBarComponent } from './features/shell/title-bar/title-bar.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; -import { NativeContextMenuComponent } from './features/shell/native-context-menu.component'; +import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component'; import { UsersActions } from './store/users/users.actions'; import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; @@ -161,7 +161,7 @@ export class App implements OnInit, OnDestroy { return; } - void import('./domains/theme/feature/settings/theme-settings.component') + void import('./domains/theme/feature/settings/theme-settings/theme-settings.component') .then((module) => { this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent); }); diff --git a/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts b/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts index 23725c5..a55568c 100644 --- a/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts +++ b/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts @@ -1,4 +1,3 @@ -/* eslint-disable complexity, padding-line-between-statements */ import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics'; import type { Room, User } from '../../models/index'; import { @@ -30,6 +29,14 @@ import type { MutableDebugNetworkNode } from '../../models/debugging.models'; +interface FinalizedNodePresentation { + userId: string | null; + label: string; + secondaryLabel: string; + title: string; + isActive: boolean; +} + export function buildDebugNetworkSnapshot( entries: readonly DebugLogEntry[], currentUser: User | null, @@ -158,223 +165,324 @@ class DebugNetworkSnapshotBuilder { const payload = this.getEntryPayloadRecord(entry.payload); const timestamp = entry.timestamp; - if ( - entry.message === 'Connecting to signaling server' - || entry.message === 'Connected to signaling server' - || entry.message === 'Attempting reconnect' - || entry.message === 'Disconnected from signaling server' - || entry.message === 'Signaling socket error' - || entry.message === 'Failed to initialize signaling socket' - ) { - const url = this.getPayloadString(payload, 'serverUrl') ?? this.getPayloadString(payload, 'url'); - - if (!url) - return; - - const serverNode = this.ensureSignalingServerNode(state, url, timestamp); - const edge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp); - - edge.stateLabel = this.getSignalingEdgeStateLabel(entry.message); - edge.isActive = edge.stateLabel === 'connected' || edge.stateLabel === 'connecting' || edge.stateLabel === 'active'; - + if (this.applySignalingServerStateChange(state, entry.message, payload, timestamp)) return; - } if (entry.message !== 'inbound' && entry.message !== 'outbound') return; const direction = entry.message as DebugNetworkMessageDirection; const type = this.getPayloadString(payload, 'type') ?? 'unknown'; - const url = this.getPayloadString(payload, 'url'); - if (url) { - const signalingNode = this.ensureSignalingServerNode(state, url, timestamp); - const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp); - - signalingEdge.isActive = true; - - if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected') - signalingEdge.stateLabel = 'active'; - - this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, entry.count); - } + this.recordSignalingTransportMessage(state, payload, type, direction, timestamp, entry.count); switch (type) { - case 'identify': { - const oderId = this.getPayloadString(payload, 'oderId'); - const displayName = this.getPayloadString(payload, 'displayName'); - - if (direction === 'outbound') - this.ensureLocalNetworkNode(state, timestamp, oderId, displayName); - - break; - } - - case 'connected': { - const oderId = this.getPayloadString(payload, 'oderId'); - - if (oderId) - this.ensureLocalNetworkNode(state, timestamp, oderId); - - break; - } - + case 'identify': + this.applySignalingIdentifyMessage(state, direction, payload, timestamp); + return; + case 'connected': + this.applySignalingConnectedMessage(state, payload, timestamp); + return; case 'join_server': case 'view_server': - case 'leave_server': { - const serverId = this.getPayloadString(payload, 'serverId'); - - if (!serverId) - return; - - const serverNode = this.ensureAppServerNode(state, serverId, timestamp); - const membershipEdge = this.ensureNetworkEdge(state, 'membership', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp); - - membershipEdge.isActive = type !== 'leave_server'; - membershipEdge.stateLabel = type === 'view_server' - ? 'viewing' - : (type === 'join_server' ? 'joined' : 'left'); - - break; - } - - case 'server_users': { - const serverId = this.getPayloadString(payload, 'serverId'); - const users = this.getPayloadArray(payload, 'users'); - - if (!serverId) - return; - - const serverNode = this.ensureAppServerNode(state, serverId, timestamp); - - for (const userValue of users) { - const userRecord = this.asRecord(userValue); - const userId = this.getStringProperty(userRecord, 'oderId'); - - if (!userId) - continue; - - const clientNode = this.ensureClientNetworkNode( - state, - userId, - timestamp, - this.getStringProperty(userRecord, 'displayName') - ); - const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp); - - membershipEdge.isActive = true; - membershipEdge.stateLabel = 'joined'; - } - - break; - } - + case 'leave_server': + this.applySignalingMembershipMessage(state, type, payload, timestamp); + return; + case 'server_users': + this.applySignalingServerUsersMessage(state, payload, timestamp); + return; case 'user_joined': case 'user_left': - case 'user_typing': { - const oderId = this.getPayloadString(payload, 'oderId'); - const displayName = this.getPayloadString(payload, 'displayName'); - const serverId = this.getPayloadString(payload, 'serverId'); - - if (!oderId) - return; - - const clientNode = this.ensureClientNetworkNode(state, oderId, timestamp, displayName); - - if (type === 'user_typing') - clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS; - - if (serverId) { - const serverNode = this.ensureAppServerNode(state, serverId, timestamp); - const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp); - - membershipEdge.isActive = type !== 'user_left'; - membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active'); - } - - break; - } - + case 'user_typing': + this.applySignalingUserActivityMessage(state, type, payload, timestamp); + return; case 'typing': this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS; - break; - + return; case 'offer': case 'answer': - case 'ice_candidate': { - const peerId = direction === 'outbound' - ? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId')) - : (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId')); - const displayName = this.getPayloadString(payload, 'displayName'); - - if (!peerId) - return; - - const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, displayName); - const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); - - this.incrementHandshakeStats(peerNode, type, direction, entry.count); - this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, entry.count); - peerEdge.isActive = true; - - if (peerEdge.stateLabel !== 'connected') - peerEdge.stateLabel = 'negotiating'; - - break; - } + case 'ice_candidate': + this.applySignalingPeerHandshakeMessage(state, type, direction, payload, timestamp, entry.count); + return; } } + private applySignalingServerStateChange( + state: DebugNetworkBuildState, + message: string, + payload: Record | null, + timestamp: number + ): boolean { + if ( + message !== 'Connecting to signaling server' + && message !== 'Connected to signaling server' + && message !== 'Attempting reconnect' + && message !== 'Disconnected from signaling server' + && message !== 'Signaling socket error' + && message !== 'Failed to initialize signaling socket' + ) { + return false; + } + + const url = this.getPayloadString(payload, 'serverUrl') ?? this.getPayloadString(payload, 'url'); + + if (!url) + return true; + + const serverNode = this.ensureSignalingServerNode(state, url, timestamp); + const edge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp); + + edge.stateLabel = this.getSignalingEdgeStateLabel(message); + edge.isActive = edge.stateLabel === 'connected' || edge.stateLabel === 'connecting' || edge.stateLabel === 'active'; + + return true; + } + + private recordSignalingTransportMessage( + state: DebugNetworkBuildState, + payload: Record | null, + type: string, + direction: DebugNetworkMessageDirection, + timestamp: number, + count: number + ): void { + const url = this.getPayloadString(payload, 'url'); + + if (!url) + return; + + const signalingNode = this.ensureSignalingServerNode(state, url, timestamp); + const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp); + + signalingEdge.isActive = true; + + if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected') + signalingEdge.stateLabel = 'active'; + + this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, count); + } + + private applySignalingIdentifyMessage( + state: DebugNetworkBuildState, + direction: DebugNetworkMessageDirection, + payload: Record | null, + timestamp: number + ): void { + if (direction !== 'outbound') + return; + + const oderId = this.getPayloadString(payload, 'oderId'); + const displayName = this.getPayloadString(payload, 'displayName'); + + this.ensureLocalNetworkNode(state, timestamp, oderId, displayName); + } + + private applySignalingConnectedMessage( + state: DebugNetworkBuildState, + payload: Record | null, + timestamp: number + ): void { + const oderId = this.getPayloadString(payload, 'oderId'); + + if (oderId) + this.ensureLocalNetworkNode(state, timestamp, oderId); + } + + private applySignalingMembershipMessage( + state: DebugNetworkBuildState, + type: 'join_server' | 'view_server' | 'leave_server', + payload: Record | null, + timestamp: number + ): void { + const serverId = this.getPayloadString(payload, 'serverId'); + + if (!serverId) + return; + + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp); + + membershipEdge.isActive = type !== 'leave_server'; + membershipEdge.stateLabel = type === 'view_server' + ? 'viewing' + : (type === 'join_server' ? 'joined' : 'left'); + } + + private applySignalingServerUsersMessage( + state: DebugNetworkBuildState, + payload: Record | null, + timestamp: number + ): void { + const serverId = this.getPayloadString(payload, 'serverId'); + const users = this.getPayloadArray(payload, 'users'); + + if (!serverId) + return; + + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + + for (const userValue of users) { + const userRecord = this.asRecord(userValue); + const userId = this.getStringProperty(userRecord, 'oderId'); + + if (!userId) + continue; + + const clientNode = this.ensureClientNetworkNode( + state, + userId, + timestamp, + this.getStringProperty(userRecord, 'displayName') + ); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp); + + membershipEdge.isActive = true; + membershipEdge.stateLabel = 'joined'; + } + } + + private applySignalingUserActivityMessage( + state: DebugNetworkBuildState, + type: 'user_joined' | 'user_left' | 'user_typing', + payload: Record | null, + timestamp: number + ): void { + const oderId = this.getPayloadString(payload, 'oderId'); + const displayName = this.getPayloadString(payload, 'displayName'); + const serverId = this.getPayloadString(payload, 'serverId'); + + if (!oderId) + return; + + const clientNode = this.ensureClientNetworkNode(state, oderId, timestamp, displayName); + + if (type === 'user_typing') + clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS; + + if (!serverId) + return; + + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp); + + membershipEdge.isActive = type !== 'user_left'; + membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active'); + } + + private applySignalingPeerHandshakeMessage( + state: DebugNetworkBuildState, + type: 'offer' | 'answer' | 'ice_candidate', + direction: DebugNetworkMessageDirection, + payload: Record | null, + timestamp: number, + count: number + ): void { + const peerId = direction === 'outbound' + ? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId')) + : (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId')); + const displayName = this.getPayloadString(payload, 'displayName'); + + if (!peerId) + return; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, displayName); + const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + + this.incrementHandshakeStats(peerNode, type, direction, count); + this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, count); + peerEdge.isActive = true; + + if (peerEdge.stateLabel !== 'connected') + peerEdge.stateLabel = 'negotiating'; + } + private applyDataChannelNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { const payload = this.getEntryPayloadRecord(entry.payload); const timestamp = entry.timestamp; - if (entry.message === 'Peer latency updated') { - const peerId = this.getPayloadString(payload, 'peerId'); - const latencyMs = this.getPayloadNumber(payload, 'latencyMs'); - - if (!peerId || latencyMs === null) - return; - - const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); - const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); - - edge.isActive = true; - edge.pingMs = latencyMs; - edge.stateLabel = 'connected'; - peerNode.pingMs = latencyMs; - + if (this.applyDataChannelLatencyMessage(state, entry.message, payload, timestamp)) return; - } - - if (entry.message === 'Data channel open' || entry.message === 'Data channel closed' || entry.message === 'Data channel error') { - const peerId = this.getPayloadString(payload, 'peerId'); - - if (!peerId) - return; - - const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); - const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); - - if (entry.message === 'Data channel open') { - edge.isActive = true; - edge.stateLabel = 'connected'; - } else if (entry.message === 'Data channel closed') { - edge.isActive = false; - edge.stateLabel = 'closed'; - peerNode.isSpeaking = false; - } else { - edge.isActive = false; - edge.stateLabel = 'error'; - } + if (this.applyDataChannelStateMessage(state, entry.message, payload, timestamp)) return; - } if (entry.message !== 'inbound' && entry.message !== 'outbound') return; - const direction = entry.message as DebugNetworkMessageDirection; + this.applyDataChannelPayloadMessage( + state, + payload, + entry.message as DebugNetworkMessageDirection, + timestamp, + entry.count + ); + } + + private applyDataChannelLatencyMessage( + state: DebugNetworkBuildState, + message: string, + payload: Record | null, + timestamp: number + ): boolean { + if (message !== 'Peer latency updated') + return false; + + const peerId = this.getPayloadString(payload, 'peerId'); + const latencyMs = this.getPayloadNumber(payload, 'latencyMs'); + + if (!peerId || latencyMs === null) + return true; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); + const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + + edge.isActive = true; + edge.pingMs = latencyMs; + edge.stateLabel = 'connected'; + peerNode.pingMs = latencyMs; + + return true; + } + + private applyDataChannelStateMessage( + state: DebugNetworkBuildState, + message: string, + payload: Record | null, + timestamp: number + ): boolean { + if (message !== 'Data channel open' && message !== 'Data channel closed' && message !== 'Data channel error') + return false; + + const peerId = this.getPayloadString(payload, 'peerId'); + + if (!peerId) + return true; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); + const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + + if (message === 'Data channel open') { + edge.isActive = true; + edge.stateLabel = 'connected'; + return true; + } + + edge.isActive = false; + edge.stateLabel = message === 'Data channel closed' ? 'closed' : 'error'; + + if (message === 'Data channel closed') + peerNode.isSpeaking = false; + + return true; + } + + private applyDataChannelPayloadMessage( + state: DebugNetworkBuildState, + payload: Record | null, + direction: DebugNetworkMessageDirection, + timestamp: number, + count: number + ): void { const peerId = this.getPayloadString(payload, 'peerId'); if (!peerId) @@ -386,16 +494,16 @@ class DebugNetworkSnapshotBuilder { edge.isActive = true; edge.stateLabel = 'connected'; - this.recordNetworkMessage(edge, type, direction, 'data-channel', timestamp, entry.count); + this.recordNetworkMessage(edge, type, direction, 'data-channel', timestamp, count); if (type === 'chat-message' || type === 'message') - this.incrementTextMessageCount(peerNode, direction, entry.count); + this.incrementTextMessageCount(peerNode, direction, count); if (type === 'file-chunk' && direction === 'inbound') { const bytes = this.getPayloadNumber(payload, 'bytes'); if (bytes !== null) - this.recordFileTransferSample(peerNode, timestamp, bytes, entry.count); + this.recordFileTransferSample(peerNode, timestamp, bytes, count); } if (direction === 'outbound') { @@ -412,49 +520,72 @@ class DebugNetworkSnapshotBuilder { } if (type === 'voice-state') { - const voiceState = this.getPayloadRecord(payload, 'voiceState'); - const subjectNode = direction === 'outbound' - ? this.ensureLocalNetworkNode( - state, - timestamp, - this.getPayloadString(payload, 'oderId'), - this.getPayloadString(payload, 'displayName') - ) - : peerNode; - - this.applyVoiceStateToNetworkNode(subjectNode, voiceState); - - const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId'); - - if (serverId) { - const serverNode = this.ensureAppServerNode(state, serverId, timestamp); - const membershipEdge = this.ensureNetworkEdge(state, 'membership', subjectNode.id, serverNode.id, timestamp); - - membershipEdge.isActive = true; - membershipEdge.stateLabel = 'joined'; - } + this.applyVoiceStateDataChannelMessage(state, payload, direction, peerNode, timestamp); + return; } - if (type === 'screen-state' || type === 'camera-state') { - const subjectNode = direction === 'outbound' - ? this.ensureLocalNetworkNode( - state, - timestamp, - this.getPayloadString(payload, 'oderId'), - this.getPayloadString(payload, 'displayName') - ) - : peerNode; - const isStreaming = type === 'screen-state' - ? this.getPayloadBoolean(payload, 'isScreenSharing') - : this.getPayloadBoolean(payload, 'isCameraEnabled'); + if (type === 'screen-state' || type === 'camera-state') + this.applyStreamingStateDataChannelMessage(state, type, payload, direction, peerNode, timestamp); + } - if (isStreaming !== null) { - subjectNode.isStreaming = isStreaming; + private applyVoiceStateDataChannelMessage( + state: DebugNetworkBuildState, + payload: Record | null, + direction: DebugNetworkMessageDirection, + peerNode: MutableDebugNetworkNode, + timestamp: number + ): void { + const voiceState = this.getPayloadRecord(payload, 'voiceState'); + const subjectNode = direction === 'outbound' + ? this.ensureLocalNetworkNode( + state, + timestamp, + this.getPayloadString(payload, 'oderId'), + this.getPayloadString(payload, 'displayName') + ) + : peerNode; - if (!isStreaming) - subjectNode.streams.video = 0; - } - } + this.applyVoiceStateToNetworkNode(subjectNode, voiceState); + + const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId'); + + if (!serverId) + return; + + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', subjectNode.id, serverNode.id, timestamp); + + membershipEdge.isActive = true; + membershipEdge.stateLabel = 'joined'; + } + + private applyStreamingStateDataChannelMessage( + state: DebugNetworkBuildState, + type: 'screen-state' | 'camera-state', + payload: Record | null, + direction: DebugNetworkMessageDirection, + peerNode: MutableDebugNetworkNode, + timestamp: number + ): void { + const subjectNode = direction === 'outbound' + ? this.ensureLocalNetworkNode( + state, + timestamp, + this.getPayloadString(payload, 'oderId'), + this.getPayloadString(payload, 'displayName') + ) + : peerNode; + const isStreaming = type === 'screen-state' + ? this.getPayloadBoolean(payload, 'isScreenSharing') + : this.getPayloadBoolean(payload, 'isCameraEnabled'); + + if (isStreaming === null) + return; + + subjectNode.isStreaming = isStreaming; + + if (!isStreaming) + subjectNode.streams.video = 0; } private applyGenericWebRTCNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { @@ -471,73 +602,97 @@ class DebugNetworkSnapshotBuilder { switch (entry.message) { case 'Creating peer connection': case 'Received data channel': - edge.isActive = true; - - if (edge.stateLabel !== 'connected') - edge.stateLabel = 'connecting'; - + this.markPeerEdgeConnecting(edge); return; - case 'connectionstatechange': { - const connectionState = this.getStringProperty(payload, 'state'); - - if (!connectionState) - return; - - const previousState = edge.stateLabel; - - edge.stateLabel = connectionState; - edge.isActive = connectionState === 'connected' || connectionState === 'connecting'; - - if (!edge.isActive) { - peerNode.isSpeaking = false; - - if (connectionState === 'disconnected' || connectionState === 'failed') { - if (previousState !== connectionState) - peerNode.connectionDrops += 1; - - peerNode.streams.audio = 0; - peerNode.streams.video = 0; - } - } - + case 'connectionstatechange': + this.applyGenericConnectionStateMessage(peerNode, edge, payload); return; - } - - case 'Remote stream updated': { - const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount'); - const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount'); - - edge.isActive = true; - - if (!edge.stateLabel || edge.stateLabel === 'negotiating') - edge.stateLabel = 'connected'; - - if (audioTrackCount !== null) - peerNode.streams.audio = Math.max(0, Math.round(audioTrackCount)); - - if (videoTrackCount !== null) - peerNode.streams.video = Math.max(0, Math.round(videoTrackCount)); + case 'Remote stream updated': + this.applyGenericRemoteStreamMessage(peerNode, edge, payload); return; - } - - case 'Peer transport stats': { - edge.isActive = true; - - if (!edge.stateLabel || edge.stateLabel === 'negotiating') - edge.stateLabel = 'connected'; - - this.updateNodeDownloadStats(peerNode, { - audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'), - videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps') - }, timestamp); + case 'Peer transport stats': + this.applyGenericTransportStatsMessage(peerNode, edge, payload, timestamp); return; - } } } + private markPeerEdgeConnecting(edge: MutableDebugNetworkEdge): void { + edge.isActive = true; + + if (edge.stateLabel !== 'connected') + edge.stateLabel = 'connecting'; + } + + private applyGenericConnectionStateMessage( + peerNode: MutableDebugNetworkNode, + edge: MutableDebugNetworkEdge, + payload: Record | null + ): void { + const connectionState = this.getStringProperty(payload, 'state'); + + if (!connectionState) + return; + + const previousState = edge.stateLabel; + + edge.stateLabel = connectionState; + edge.isActive = connectionState === 'connected' || connectionState === 'connecting'; + + if (edge.isActive) + return; + + peerNode.isSpeaking = false; + + if (connectionState !== 'disconnected' && connectionState !== 'failed') + return; + + if (previousState !== connectionState) + peerNode.connectionDrops += 1; + + peerNode.streams.audio = 0; + peerNode.streams.video = 0; + } + + private applyGenericRemoteStreamMessage( + peerNode: MutableDebugNetworkNode, + edge: MutableDebugNetworkEdge, + payload: Record | null + ): void { + const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount'); + const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount'); + + edge.isActive = true; + + if (!edge.stateLabel || edge.stateLabel === 'negotiating') + edge.stateLabel = 'connected'; + + if (audioTrackCount !== null) + peerNode.streams.audio = Math.max(0, Math.round(audioTrackCount)); + + if (videoTrackCount !== null) + peerNode.streams.video = Math.max(0, Math.round(videoTrackCount)); + } + + private applyGenericTransportStatsMessage( + peerNode: MutableDebugNetworkNode, + edge: MutableDebugNetworkEdge, + payload: Record | null, + timestamp: number + ): void { + edge.isActive = true; + + if (!edge.stateLabel || edge.stateLabel === 'negotiating') + edge.stateLabel = 'connected'; + + this.updateNodeDownloadStats(peerNode, { + audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'), + videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps') + }, timestamp); + } + private applyVoiceActivityNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { const payload = this.getEntryPayloadRecord(entry.payload); const peerId = this.getStringProperty(payload, 'peerId'); @@ -558,66 +713,102 @@ class DebugNetworkSnapshotBuilder { const localVoiceServerId = localUser ? this.getUserVoiceServerId(localUser) : null; const localVoiceConnected = localUser?.voiceState?.isConnected === true; - if (localUser && localIdentity) { - const localNode = this.ensureLocalNetworkNode(state, now, localIdentity, localUser.displayName); - - this.applyUserStateToNetworkNode(localNode, localUser, now); - - if (localVoiceServerId) { - const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now); - const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now); - - membershipEdge.isActive = true; - membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing'; - membershipEdge.lastSeen = now; - } - } + this.applyLiveLocalUserState(state, localUser, localIdentity, localVoiceServerId, localVoiceConnected, now); for (const user of state.users) { - const identity = this.getUserNetworkIdentity(user); - - if (!identity) - continue; - - const node = this.isLocalIdentity(state, identity) - ? this.ensureLocalNetworkNode(state, now, identity, user.displayName) - : this.ensureClientNetworkNode(state, identity, now, user.displayName); - - this.applyUserStateToNetworkNode(node, user, now); - - const voiceServerId = this.getUserVoiceServerId(user); - - if (voiceServerId) { - const serverNode = this.ensureAppServerNode(state, voiceServerId, now); - const membershipEdge = this.ensureNetworkEdge(state, 'membership', node.id, serverNode.id, now); - - membershipEdge.isActive = user.voiceState?.isConnected === true; - membershipEdge.stateLabel = membershipEdge.isActive ? 'joined' : 'inactive'; - - if (membershipEdge.isActive) - membershipEdge.lastSeen = now; - } - - if ( - !this.isLocalIdentity(state, identity) - && localVoiceConnected - && user.voiceState?.isConnected === true - && voiceServerId - && localVoiceServerId - && voiceServerId === localVoiceServerId - ) { - const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, node.id, now); - - peerEdge.isActive = true; - peerEdge.stateLabel = 'connected'; - peerEdge.lastSeen = now; - - if (node.pingMs !== null) - peerEdge.pingMs = node.pingMs; - } + this.applyLiveRemoteUserState(state, user, localVoiceConnected, localVoiceServerId, now); } } + private applyLiveLocalUserState( + state: DebugNetworkBuildState, + localUser: User | null, + localIdentity: string | null, + localVoiceServerId: string | null, + localVoiceConnected: boolean, + now: number + ): void { + if (!localUser || !localIdentity) + return; + + const localNode = this.ensureLocalNetworkNode(state, now, localIdentity, localUser.displayName); + + this.applyUserStateToNetworkNode(localNode, localUser, now); + + if (!localVoiceServerId) + return; + + const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now); + + membershipEdge.isActive = true; + membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing'; + membershipEdge.lastSeen = now; + } + + private applyLiveRemoteUserState( + state: DebugNetworkBuildState, + user: User, + localVoiceConnected: boolean, + localVoiceServerId: string | null, + now: number + ): void { + const identity = this.getUserNetworkIdentity(user); + + if (!identity) + return; + + const node = this.isLocalIdentity(state, identity) + ? this.ensureLocalNetworkNode(state, now, identity, user.displayName) + : this.ensureClientNetworkNode(state, identity, now, user.displayName); + + this.applyUserStateToNetworkNode(node, user, now); + + const voiceServerId = this.getUserVoiceServerId(user); + + this.syncLiveUserMembershipEdge(state, node, voiceServerId, user.voiceState?.isConnected === true, now); + + if ( + this.isLocalIdentity(state, identity) + || !localVoiceConnected + || user.voiceState?.isConnected !== true + || !voiceServerId + || !localVoiceServerId + || voiceServerId !== localVoiceServerId + ) { + return; + } + + const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, node.id, now); + + peerEdge.isActive = true; + peerEdge.stateLabel = 'connected'; + peerEdge.lastSeen = now; + + if (node.pingMs !== null) + peerEdge.pingMs = node.pingMs; + } + + private syncLiveUserMembershipEdge( + state: DebugNetworkBuildState, + node: MutableDebugNetworkNode, + voiceServerId: string | null, + isVoiceConnected: boolean, + now: number + ): void { + if (!voiceServerId) + return; + + const serverNode = this.ensureAppServerNode(state, voiceServerId, now); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', node.id, serverNode.id, now); + + membershipEdge.isActive = isVoiceConnected; + membershipEdge.stateLabel = isVoiceConnected ? 'joined' : 'inactive'; + + if (isVoiceConnected) + membershipEdge.lastSeen = now; + } + private applyUserStateToNetworkNode(node: MutableDebugNetworkNode, user: User, now: number): void { node.label = user.displayName || user.username || node.label; node.secondaryLabel = user.username ? `@${user.username}` : node.secondaryLabel; @@ -700,6 +891,7 @@ class DebugNetworkSnapshotBuilder { node.label = currentRoom?.id === serverId ? currentRoom.name : (node.label || `Server ${this.shortenIdentifier(serverId)}`); + node.secondaryLabel = serverId; node.title = node.label; @@ -898,6 +1090,7 @@ class DebugNetworkSnapshotBuilder { bytes: bytes * count, timestamp }); + node.fileTransferSamples = node.fileTransferSamples.filter( (sample) => timestamp - sample.timestamp <= NETWORK_FILE_TRANSFER_RATE_WINDOW_MS ); @@ -973,22 +1166,90 @@ class DebugNetworkSnapshotBuilder { this.applyLivePeerMetricsToNetworkNode(node, node.identity, now); const isTyping = node.typingExpiresAt !== null && node.typingExpiresAt > now; - const user = node.identity ? state.userLookup.get(node.identity) : null; - const userId = node.userId || user?.id || null; - const label = node.kind === 'local-client' - ? (state.currentUser?.displayName || state.currentUser?.username || node.label || 'You') - : (user?.displayName || user?.username || node.label); - const secondaryLabel = node.kind === 'local-client' - ? (state.currentUser?.username ? `@${state.currentUser.username}` : 'You') - : (user?.username ? `@${user.username}` : node.secondaryLabel); - const title = node.kind === 'local-client' - ? 'Current client' - : node.title; - const streams = { + const user = node.identity ? state.userLookup.get(node.identity) : undefined; + const presentation = this.buildFinalizedNodePresentation(node, state, user, hasActiveEdge); + const streams = this.buildFinalizedNodeStreams(node); + const downloads = this.getFreshDownloadStats(node, now); + const statuses = this.buildFinalizedNodeStatuses(node, streams, isTyping, hasActiveEdge); + + return { + id: node.id, + identity: node.identity, + userId: presentation.userId, + kind: node.kind, + label: presentation.label, + secondaryLabel: presentation.secondaryLabel, + title: presentation.title, + statuses, + isActive: presentation.isActive, + isVoiceConnected: node.isVoiceConnected, + isTyping, + isSpeaking: node.isSpeaking, + isMuted: node.isMuted, + isDeafened: node.isDeafened, + isStreaming: node.isStreaming, + connectionDrops: node.connectionDrops, + downloads, + handshake: { ...node.handshake }, + pingMs: node.pingMs, + streams, + textMessages: { ...node.textMessages }, + lastSeen: node.lastSeen + }; + } + + private buildFinalizedNodePresentation( + node: MutableDebugNetworkNode, + state: DebugNetworkBuildState, + user: User | undefined, + hasActiveEdge: boolean + ): FinalizedNodePresentation { + if (node.kind === 'local-client') + return this.buildLocalNodePresentation(node, state); + + return this.buildRemoteNodePresentation(node, user, hasActiveEdge); + } + + private buildLocalNodePresentation( + node: MutableDebugNetworkNode, + state: DebugNetworkBuildState + ): FinalizedNodePresentation { + return { + userId: node.userId || state.currentUser?.id || null, + label: state.currentUser?.displayName || state.currentUser?.username || node.label || 'You', + secondaryLabel: state.currentUser?.username ? `@${state.currentUser.username}` : 'You', + title: 'Current client', + isActive: true + }; + } + + private buildRemoteNodePresentation( + node: MutableDebugNetworkNode, + user: User | undefined, + hasActiveEdge: boolean + ): FinalizedNodePresentation { + return { + userId: node.userId || user?.id || null, + label: user?.displayName || user?.username || node.label, + secondaryLabel: user?.username ? `@${user.username}` : node.secondaryLabel, + title: node.title, + isActive: hasActiveEdge || node.isActive + }; + } + + private buildFinalizedNodeStreams(node: MutableDebugNetworkNode): { audio: number; video: number } { + return { audio: node.streams.audio > 0 ? node.streams.audio : (node.isVoiceConnected ? 1 : 0), video: node.streams.video > 0 ? node.streams.video : (node.isStreaming ? 1 : 0) }; - const downloads = this.getFreshDownloadStats(node, now); + } + + private buildFinalizedNodeStatuses( + node: MutableDebugNetworkNode, + streams: { audio: number; video: number }, + isTyping: boolean, + hasActiveEdge: boolean + ): string[] { const statuses: string[] = []; if (node.kind === 'local-client' || node.kind === 'remote-client') { @@ -1018,36 +1279,19 @@ class DebugNetworkSnapshotBuilder { if (node.connectionDrops > 0) statuses.push(this.buildStreamStatus(node.connectionDrops, 'drop')); - } else if (node.kind === 'signaling-server') { - statuses.push(hasActiveEdge ? 'connected' : 'idle'); - } else if (hasActiveEdge) { - statuses.push('active'); + + return statuses; } - return { - id: node.id, - identity: node.identity, - userId, - kind: node.kind, - label, - secondaryLabel, - title, - statuses, - isActive: node.kind === 'local-client' || hasActiveEdge || node.isActive, - isVoiceConnected: node.isVoiceConnected, - isTyping, - isSpeaking: node.isSpeaking, - isMuted: node.isMuted, - isDeafened: node.isDeafened, - isStreaming: node.isStreaming, - connectionDrops: node.connectionDrops, - downloads, - handshake: { ...node.handshake }, - pingMs: node.pingMs, - streams, - textMessages: { ...node.textMessages }, - lastSeen: node.lastSeen - }; + if (node.kind === 'signaling-server') { + statuses.push(hasActiveEdge ? 'connected' : 'idle'); + return statuses; + } + + if (hasActiveEdge) + statuses.push('active'); + + return statuses; } private applyLivePeerMetricsToNetworkNode( diff --git a/toju-app/src/app/core/services/user-status.service.ts b/toju-app/src/app/core/services/user-status.service.ts index 8e48cd1..04ac65a 100644 --- a/toju-app/src/app/core/services/user-status.service.ts +++ b/toju-app/src/app/core/services/user-status.service.ts @@ -14,6 +14,15 @@ import { UserStatus } from '../../shared-kernel'; const BROWSER_IDLE_POLL_MS = 10_000; const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes +interface ElectronIdleApi { + onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void; + getIdleState: () => Promise<'active' | 'idle'>; +} + +type IdleAwareWindow = Window & { + electronAPI?: ElectronIdleApi; +}; + /** * Orchestrates user status based on idle detection (Electron powerMonitor * or browser-fallback) and manual overrides (e.g. Do Not Disturb). @@ -27,6 +36,8 @@ export class UserStatusService implements OnDestroy { private zone = inject(NgZone); private webrtc = inject(RealtimeSessionFacade); private audio = inject(NotificationAudioService); + private readonly manualStatus = this.store.selectSignal(selectManualStatus); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); private electronCleanup: (() => void) | null = null; private browserPollTimer: ReturnType | null = null; @@ -41,7 +52,7 @@ export class UserStatusService implements OnDestroy { this.started = true; - if ((window as any).electronAPI?.onIdleStateChanged) { + if (this.getElectronIdleApi()?.onIdleStateChanged) { this.startElectronIdleDetection(); } else { this.startBrowserIdleDetection(); @@ -77,10 +88,10 @@ export class UserStatusService implements OnDestroy { } private startElectronIdleDetection(): void { - const api = (window as { electronAPI?: { - onIdleStateChanged: (cb: (state: 'active' | 'idle') => void) => () => void; - getIdleState: () => Promise<'active' | 'idle'>; - }; }).electronAPI!; + const api = this.getElectronIdleApi(); + + if (!api) + return; this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => { this.zone.run(() => { @@ -138,13 +149,13 @@ export class UserStatusService implements OnDestroy { } private applyAutoStatusIfAllowed(): void { - const manualStatus = this.store.selectSignal(selectManualStatus)(); + const manualStatus = this.manualStatus(); // Manual status overrides automatic if (manualStatus) return; - const currentUser = this.store.selectSignal(selectCurrentUser)(); + const currentUser = this.currentUser(); if (currentUser?.status !== this.currentAutoStatus) { this.store.dispatch(UsersActions.setManualStatus({ status: null })); @@ -163,4 +174,8 @@ export class UserStatusService implements OnDestroy { status }); } + + private getElectronIdleApi(): ElectronIdleApi | undefined { + return (window as IdleAwareWindow).electronAPI; + } } diff --git a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html index 0dfdf25..eaa9abf 100644 --- a/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html +++ b/toju-app/src/app/domains/authentication/feature/user-bar/user-bar.component.html @@ -7,7 +7,7 @@ class="relative flex items-center justify-center w-8 h-8 rounded-full bg-secondary text-foreground text-sm font-medium hover:bg-secondary/80 transition-colors" (click)="toggleProfileCard(avatarBtn)" > - {{ user()!.displayName?.charAt(0)?.toUpperCase() || '?' }} + {{ user()!.displayName.charAt(0).toUpperCase() || '?' }} m[0]); + return [...content.matchAll(URL_PATTERN)].map((match) => match[0]); } async fetchMetadata(url: string): Promise { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed/chat-link-embed.component.html similarity index 100% rename from toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.html rename to toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed/chat-link-embed.component.html diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed/chat-link-embed.component.ts similarity index 88% rename from toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.ts rename to toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed/chat-link-embed.component.ts index 734d4cd..acbe6e0 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-link-embed/chat-link-embed.component.ts @@ -5,7 +5,7 @@ import { } from '@angular/core'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideX } from '@ng-icons/lucide'; -import { LinkMetadata } from '../../../../../../shared-kernel'; +import { LinkMetadata } from '../../../../../../../shared-kernel'; @Component({ selector: 'app-chat-link-embed', diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 586a786..f978701 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -43,8 +43,8 @@ import { ProfileCardService, UserAvatarComponent } from '../../../../../../shared'; -import { ChatMessageMarkdownComponent } from './chat-message-markdown.component'; -import { ChatLinkEmbedComponent } from './chat-link-embed.component'; +import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component'; +import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.component'; import { ChatMessageDeleteEvent, ChatMessageEditEvent, @@ -155,7 +155,7 @@ export class ChatMessageItemComponent { const msg = this.message(); // Look up full user from store const users = this.allUsers(); - const found = users.find((u) => u.id === msg.senderId || u.oderId === msg.senderId); + const found = users.find((userEntry) => userEntry.id === msg.senderId || userEntry.oderId === msg.senderId); const user: User = found ?? { id: msg.senderId, oderId: msg.senderId, diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.html similarity index 100% rename from toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown.component.html rename to toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.html diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts similarity index 90% rename from toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown.component.ts rename to toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts index e929c11..51b4217 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts @@ -5,8 +5,8 @@ import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import { unified } from 'unified'; -import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; -import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component'; +import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive'; +import { ChatYoutubeEmbedComponent, isYoutubeUrl } from '../chat-youtube-embed/chat-youtube-embed.component'; const PRISM_LANGUAGE_ALIASES: Record = { cs: 'csharp', diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.html new file mode 100644 index 0000000..f44c03c --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.html @@ -0,0 +1,11 @@ +@if (videoId()) { +
+ +
+} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts similarity index 64% rename from toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed.component.ts rename to toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts index 00212e1..5a179cf 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts @@ -1,6 +1,7 @@ import { Component, computed, + inject, input } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; @@ -10,19 +11,7 @@ const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|y @Component({ selector: 'app-chat-youtube-embed', standalone: true, - template: ` - @if (videoId()) { -
- -
- } - ` + templateUrl: './chat-youtube-embed.component.html' }) export class ChatYoutubeEmbedComponent { readonly url = input.required(); @@ -44,7 +33,7 @@ export class ChatYoutubeEmbedComponent { ); }); - constructor(private readonly sanitizer: DomSanitizer) {} + private readonly sanitizer = inject(DomSanitizer); } export function isYoutubeUrl(url?: string): boolean { diff --git a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.html b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.html similarity index 100% rename from toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.html rename to toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.html diff --git a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.ts b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts similarity index 93% rename from toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.ts rename to toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts index b25da4a..a6b88a4 100644 --- a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.ts +++ b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts @@ -13,9 +13,9 @@ import { lucideMessageSquareText, lucideMoonStar } from '@ng-icons/lucide'; -import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; -import type { Room } from '../../../../shared-kernel'; -import { NotificationsFacade } from '../../application/facades/notifications.facade'; +import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors'; +import type { Room } from '../../../../../shared-kernel'; +import { NotificationsFacade } from '../../../application/facades/notifications.facade'; @Component({ selector: 'app-notifications-settings', diff --git a/toju-app/src/app/domains/notifications/index.ts b/toju-app/src/app/domains/notifications/index.ts index 6f819a4..e5a801a 100644 --- a/toju-app/src/app/domains/notifications/index.ts +++ b/toju-app/src/app/domains/notifications/index.ts @@ -1,3 +1,3 @@ export * from './application/facades/notifications.facade'; export * from './application/effects/notifications.effects'; -export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component'; +export { NotificationsSettingsComponent } from './feature/settings/notifications-settings/notifications-settings.component'; diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.html b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor/theme-grid-editor.component.html similarity index 100% rename from toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.html rename to toju-app/src/app/domains/theme/feature/settings/theme-grid-editor/theme-grid-editor.component.html diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.scss b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor/theme-grid-editor.component.scss similarity index 100% rename from toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.scss rename to toju-app/src/app/domains/theme/feature/settings/theme-grid-editor/theme-grid-editor.component.scss diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor/theme-grid-editor.component.ts similarity index 98% rename from toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.ts rename to toju-app/src/app/domains/theme/feature/settings/theme-grid-editor/theme-grid-editor.component.ts index 5108076..49c4dc4 100644 --- a/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor.component.ts +++ b/toju-app/src/app/domains/theme/feature/settings/theme-grid-editor/theme-grid-editor.component.ts @@ -14,7 +14,7 @@ import { ThemeGridEditorItem, ThemeGridRect, ThemeLayoutContainerDefinition -} from '../../domain/models/theme.model'; +} from '../../../domain/models/theme.model'; type DragMode = 'move' | 'resize'; diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.html b/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor/theme-json-code-editor.component.html similarity index 100% rename from toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.html rename to toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor/theme-json-code-editor.component.html diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.scss b/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor/theme-json-code-editor.component.scss similarity index 100% rename from toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.scss rename to toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor/theme-json-code-editor.component.scss diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor/theme-json-code-editor.component.ts similarity index 100% rename from toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor.component.ts rename to toju-app/src/app/domains/theme/feature/settings/theme-json-code-editor/theme-json-code-editor.component.ts diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.html b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.html similarity index 100% rename from toju-app/src/app/domains/theme/feature/settings/theme-settings.component.html rename to toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.html diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.scss b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.scss similarity index 100% rename from toju-app/src/app/domains/theme/feature/settings/theme-settings.component.scss rename to toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.scss diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts similarity index 93% rename from toju-app/src/app/domains/theme/feature/settings/theme-settings.component.ts rename to toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts index d529d8b..8efbaa4 100644 --- a/toju-app/src/app/domains/theme/feature/settings/theme-settings.component.ts +++ b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts @@ -8,26 +8,26 @@ import { } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { SettingsModalService } from '../../../../core/services/settings-modal.service'; +import { SettingsModalService } from '../../../../../core/services/settings-modal.service'; import { ThemeContainerKey, ThemeElementStyleProperty, ThemeRegistryEntry -} from '../../domain/models/theme.model'; +} from '../../../domain/models/theme.model'; import { THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS, THEME_ELEMENT_STYLE_FIELDS, createAnimationStarterDefinition, getSuggestedFieldDefault -} from '../../domain/logic/theme-schema.logic'; -import { ElementPickerService } from '../../application/services/element-picker.service'; -import { LayoutSyncService } from '../../application/services/layout-sync.service'; -import { ThemeLibraryService } from '../../application/services/theme-library.service'; -import { ThemeRegistryService } from '../../application/services/theme-registry.service'; -import { ThemeService } from '../../application/services/theme.service'; -import { THEME_LLM_GUIDE } from '../../domain/constants/theme-llm-guide.constants'; -import { ThemeGridEditorComponent } from './theme-grid-editor.component'; -import { ThemeJsonCodeEditorComponent } from './theme-json-code-editor.component'; +} from '../../../domain/logic/theme-schema.logic'; +import { ElementPickerService } from '../../../application/services/element-picker.service'; +import { LayoutSyncService } from '../../../application/services/layout-sync.service'; +import { ThemeLibraryService } from '../../../application/services/theme-library.service'; +import { ThemeRegistryService } from '../../../application/services/theme-registry.service'; +import { ThemeService } from '../../../application/services/theme.service'; +import { THEME_LLM_GUIDE } from '../../../domain/constants/theme-llm-guide.constants'; +import { ThemeGridEditorComponent } from '../theme-grid-editor/theme-grid-editor.component'; +import { ThemeJsonCodeEditorComponent } from '../theme-json-code-editor/theme-json-code-editor.component'; type JumpSection = 'elements' | 'layout' | 'animations'; type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout'; diff --git a/toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.html b/toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.html similarity index 100% rename from toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.html rename to toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.html diff --git a/toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.ts b/toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.ts similarity index 76% rename from toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.ts rename to toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.ts index 403f070..dee4665 100644 --- a/toju-app/src/app/domains/theme/feature/theme-picker-overlay.component.ts +++ b/toju-app/src/app/domains/theme/feature/theme-picker-overlay/theme-picker-overlay.component.ts @@ -5,8 +5,8 @@ import { } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ElementPickerService } from '../application/services/element-picker.service'; -import { ThemeRegistryService } from '../application/services/theme-registry.service'; +import { ElementPickerService } from '../../application/services/element-picker.service'; +import { ThemeRegistryService } from '../../application/services/theme-registry.service'; @Component({ selector: 'app-theme-picker-overlay', diff --git a/toju-app/src/app/domains/theme/index.ts b/toju-app/src/app/domains/theme/index.ts index 45a14e4..310ad62 100644 --- a/toju-app/src/app/domains/theme/index.ts +++ b/toju-app/src/app/domains/theme/index.ts @@ -10,4 +10,4 @@ export * from './domain/logic/theme-schema.logic'; export * from './domain/logic/theme-validation.logic'; export { ThemeNodeDirective } from './feature/theme-node.directive'; -export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component'; +export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay/theme-picker-overlay.component'; diff --git a/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts b/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts index e336b08..cc2a30c 100644 --- a/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts +++ b/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts @@ -52,16 +52,13 @@ export class VoiceWorkspaceService { readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition()); constructor() { - effect( - () => { - if (this.voiceSession.voiceSession()) { - return; - } + effect(() => { + if (this.voiceSession.voiceSession()) { + return; + } - this.reset(); - }, - { allowSignalWrites: true } - ); + this.reset(); + }); } open( diff --git a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html index 083bd40..0980749 100644 --- a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html +++ b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.html @@ -17,9 +17,11 @@ /> @if (voiceSession()?.serverIcon) { } @else {
diff --git a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts index fdb7f83..7aab098 100644 --- a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts +++ b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts @@ -6,7 +6,7 @@ import { computed, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, NgOptimizedImage } from '@angular/common'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { @@ -34,6 +34,7 @@ import { ThemeNodeDirective } from '../../../../domains/theme'; standalone: true, imports: [ CommonModule, + NgOptimizedImage, NgIcon, DebugConsoleComponent, ScreenShareQualityDialogComponent, diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index bbb7c94..d985ecd 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -182,15 +182,7 @@ [name]="u.displayName" [avatarUrl]="u.avatarUrl" size="xs" - [ringClass]=" - u.voiceState?.isDeafened - ? 'ring-2 ring-red-500' - : u.voiceState?.isMuted - ? 'ring-2 ring-yellow-500' - : voiceActivity.isSpeaking(u.oderId || u.id)() - ? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]' - : 'ring-2 ring-green-500/40' - " + [ringClass]="getVoiceUserRingClass(u)" /> {{ u.displayName }} @@ -246,7 +238,11 @@

You

('channels'); readonly showVoiceControls = input(true); @@ -186,14 +186,14 @@ export class RoomsSidePanelComponent { draggedVoiceUserId = signal(null); dragTargetVoiceChannelId = signal(null); - openProfileCard(event: MouseEvent, user: User, editable: boolean): void { + openProfileCard(event: Event, user: User, editable: boolean): void { event.stopPropagation(); const el = event.currentTarget as HTMLElement; this.profileCard.open(el, user, { placement: 'left', editable }); } - openProfileCardForMember(event: MouseEvent, member: RoomMember): void { + openProfileCardForMember(event: Event, member: RoomMember): void { const user: User = { id: member.id, oderId: member.oderId || member.id, @@ -886,6 +886,22 @@ export class RoomsSidePanelComponent { return this.isUserSharing(userId) || this.isUserOnCamera(userId); } + getVoiceUserRingClass(user: User): string { + if (user.voiceState?.isDeafened) { + return 'ring-2 ring-red-500'; + } + + if (user.voiceState?.isMuted) { + return 'ring-2 ring-yellow-500'; + } + + if (this.isVoiceUserSpeaking(user)) { + return 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]'; + } + + return 'ring-2 ring-green-500/40'; + } + getUserLiveIconName(userId: string): string { return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo'; } @@ -981,6 +997,12 @@ export class RoomsSidePanelComponent { return 'bg-red-500'; } + private isVoiceUserSpeaking(user: User): boolean { + const userKey = user.oderId || user.id; + + return !!userKey && this.voiceActivity.speakingMap().get(userKey) === true; + } + private findKnownUser(userId: string): User | null { const current = this.currentUser(); diff --git a/toju-app/src/app/features/room/voice-workspace/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 similarity index 100% rename from toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile.component.html rename to toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.html diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile.component.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts similarity index 90% rename from toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile.component.ts rename to toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts index aa0618a..4906149 100644 --- a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile.component.ts +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts @@ -22,9 +22,9 @@ import { lucideVolumeX } from '@ng-icons/lucide'; -import { UserAvatarComponent } from '../../../shared'; -import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service'; -import { VoiceWorkspaceStreamItem } from './voice-workspace.models'; +import { UserAvatarComponent } from '../../../../shared'; +import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service'; +import { VoiceWorkspaceStreamItem } from '../voice-workspace.models'; @Component({ selector: 'app-voice-workspace-stream-tile', @@ -86,23 +86,20 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy { void video.play().catch(() => {}); }); - effect( - () => { - this.workspacePlayback.settings(); + effect(() => { + this.workspacePlayback.settings(); - const item = this.item(); + const item = this.item(); - if (item.isLocal || !item.hasAudio) { - this.volume.set(0); - this.muted.set(false); - return; - } + if (item.isLocal || !item.hasAudio) { + this.volume.set(0); + this.muted.set(false); + return; + } - this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey)); - this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey)); - }, - { allowSignalWrites: true } - ); + this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey)); + this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey)); + }); effect(() => { const ref = this.videoRef(); diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts index 863b3f3..bdf9751 100644 --- a/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts @@ -48,7 +48,7 @@ import { UsersActions } from '../../../store/users/users.actions'; import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared'; import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service'; -import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile.component'; +import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile/voice-workspace-stream-tile.component'; import { VoiceWorkspaceStreamItem } from './voice-workspace.models'; import { ThemeNodeDirective } from '../../../domains/theme'; @@ -456,38 +456,35 @@ export class VoiceWorkspaceComponent { this.pruneObservedRemoteStreams(peerKeys); }); - effect( - () => { - const isExpanded = this.showExpanded(); - const shouldAutoHideChrome = this.shouldAutoHideChrome(); + effect(() => { + const isExpanded = this.showExpanded(); + const shouldAutoHideChrome = this.shouldAutoHideChrome(); - if (!isExpanded) { - this.clearHeaderHideTimeout(); - this.showWorkspaceHeader.set(true); - this.wasExpanded = false; - this.wasAutoHideChrome = false; - return; - } - - if (!shouldAutoHideChrome) { - this.clearHeaderHideTimeout(); - this.showWorkspaceHeader.set(true); - this.wasExpanded = true; - this.wasAutoHideChrome = false; - return; - } - - const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome; + if (!isExpanded) { + this.clearHeaderHideTimeout(); + this.showWorkspaceHeader.set(true); + this.wasExpanded = false; + this.wasAutoHideChrome = false; + return; + } + if (!shouldAutoHideChrome) { + this.clearHeaderHideTimeout(); + this.showWorkspaceHeader.set(true); this.wasExpanded = true; - this.wasAutoHideChrome = true; + this.wasAutoHideChrome = false; + return; + } - if (shouldRevealChrome) { - this.revealWorkspaceChrome(); - } - }, - { allowSignalWrites: true } - ); + const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome; + + this.wasExpanded = true; + this.wasAutoHideChrome = true; + + if (shouldRevealChrome) { + this.revealWorkspaceChrome(); + } + }); } onWorkspacePointerMove(): void { diff --git a/toju-app/src/app/features/servers/servers-rail.component.html b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.html similarity index 100% rename from toju-app/src/app/features/servers/servers-rail.component.html rename to toju-app/src/app/features/servers/servers-rail/servers-rail.component.html diff --git a/toju-app/src/app/features/servers/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts similarity index 93% rename from toju-app/src/app/features/servers/servers-rail.component.ts rename to toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts index edfd76c..cccb85e 100644 --- a/toju-app/src/app/features/servers/servers-rail.component.ts +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts @@ -26,21 +26,21 @@ import { tap } from 'rxjs'; -import { Room, User } from '../../shared-kernel'; -import { UserBarComponent } from '../../domains/authentication/feature/user-bar/user-bar.component'; -import { VoiceSessionFacade } from '../../domains/voice-session'; -import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; -import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors'; -import { RoomsActions } from '../../store/rooms/rooms.actions'; -import { DatabaseService } from '../../infrastructure/persistence'; -import { NotificationsFacade } from '../../domains/notifications'; -import { type ServerInfo, ServerDirectoryFacade } from '../../domains/server-directory'; -import { hasRoomBanForUser } from '../../domains/access-control'; +import { Room, User } from '../../../shared-kernel'; +import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component'; +import { VoiceSessionFacade } from '../../../domains/voice-session'; +import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; +import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; +import { RoomsActions } from '../../../store/rooms/rooms.actions'; +import { DatabaseService } from '../../../infrastructure/persistence'; +import { NotificationsFacade } from '../../../domains/notifications'; +import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory'; +import { hasRoomBanForUser } from '../../../domains/access-control'; import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent -} from '../../shared'; +} from '../../../shared'; @Component({ selector: 'app-servers-rail', @@ -81,8 +81,8 @@ export class ServersRailComponent { bannedRoomLookup = signal>({}); isOnSearch = toSignal( this.router.events.pipe( - filter((e): e is NavigationEnd => e instanceof NavigationEnd), - map((e) => e.urlAfterRedirects.startsWith('/search')) + filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), + map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search')) ), { initialValue: this.router.url.startsWith('/search') } ); diff --git a/toju-app/src/app/features/settings/settings-modal/members-settings/members-settings.component.html b/toju-app/src/app/features/settings/settings-modal/members-settings/members-settings.component.html index 0e50f32..800e218 100644 --- a/toju-app/src/app/features/settings/settings-modal/members-settings/members-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/members-settings/members-settings.component.html @@ -25,6 +25,7 @@
@if (canKickMembers(member)) { + + @if (showStatusMenu()) { +
+ @for (opt of statusOptions; track opt.label) { + + } +
+ } +
+ } @else { +
+ + {{ currentStatusLabel() }} +
+ } +
+
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 7f02157..4b3dd2c 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 @@ -19,80 +19,12 @@ import { User, UserStatus } from '../../../shared-kernel'; UserAvatarComponent ], viewProviders: [provideIcons({ lucideChevronDown })], - template: ` -
- -
- - -
-
- -
-
- - -
-

{{ user().displayName }}

-

{{ user().username }}

- - @if (editable()) { - -
- - @if (showStatusMenu()) { -
- @for (opt of statusOptions; track opt.label) { - - } -
- } -
- } @else { -
- - {{ currentStatusLabel() }} -
- } - -
-
- ` + templateUrl: './profile-card.component.html' }) export class ProfileCardComponent { - user = signal({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); - editable = signal(false); - - private userStatus = inject(UserStatusService); - showStatusMenu = signal(false); + readonly user = signal({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); + readonly editable = signal(false); + readonly showStatusMenu = signal(false); readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [ { value: null, label: 'Online', color: 'bg-green-500' }, @@ -101,6 +33,8 @@ export class ProfileCardComponent { { value: 'offline', label: 'Invisible', color: 'bg-gray-500' } ]; + private readonly userStatus = inject(UserStatusService); + currentStatusColor(): string { switch (this.user().status) { case 'online': return 'bg-green-500'; @@ -124,7 +58,7 @@ export class ProfileCardComponent { } toggleStatusMenu(): void { - this.showStatusMenu.update((v) => !v); + this.showStatusMenu.update((isOpen) => !isOpen); } setStatus(status: UserStatus | null): void { diff --git a/toju-app/src/app/shared/components/user-volume-menu/user-volume-menu.component.ts b/toju-app/src/app/shared/components/user-volume-menu/user-volume-menu.component.ts index 92a51ce..c70bec2 100644 --- a/toju-app/src/app/shared/components/user-volume-menu/user-volume-menu.component.ts +++ b/toju-app/src/app/shared/components/user-volume-menu/user-volume-menu.component.ts @@ -21,9 +21,7 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component'; }) /* eslint-disable @typescript-eslint/member-ordering */ export class UserVolumeMenuComponent implements OnInit { - // eslint-disable-next-line id-length, id-denylist x = input.required(); - // eslint-disable-next-line id-length, id-denylist y = input.required(); peerId = input.required(); displayName = input.required();