refactor: Clean lint errors and organise files
This commit is contained in:
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -11,9 +11,15 @@ import { type Page } from '@playwright/test';
|
||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const connections: RTCPeerConnection[] = [];
|
||||
const syntheticMediaResources: {
|
||||
audioCtx: AudioContext;
|
||||
source?: AudioScheduledSourceNode;
|
||||
drawIntervalId?: number;
|
||||
}[] = [];
|
||||
|
||||
(window as any).__rtcConnections = connections;
|
||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||
|
||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||
|
||||
@@ -55,18 +61,37 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -59,6 +59,9 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
||||
return createHttpServer(app);
|
||||
}
|
||||
|
||||
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const variablesConfig = ensureVariablesConfig();
|
||||
const serverProtocol = getServerProtocol();
|
||||
@@ -86,10 +89,12 @@ async function bootstrap(): Promise<void> {
|
||||
const app = createApp();
|
||||
const server = buildServer(app, serverProtocol);
|
||||
|
||||
listeningServer = server;
|
||||
|
||||
setupWebSocket(server);
|
||||
|
||||
// Periodically clean up stale join requests (older than 24 h)
|
||||
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<void> {
|
||||
|
||||
shuttingDown = true;
|
||||
|
||||
if (staleJoinRequestInterval) {
|
||||
clearInterval(staleJoinRequestInterval);
|
||||
staleJoinRequestInterval = null;
|
||||
}
|
||||
|
||||
console.log(`\n[Shutdown] ${signal} received - closing database…`);
|
||||
|
||||
if (listeningServer?.listening) {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
listeningServer?.close(() => resolve());
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Shutdown] Error closing server:', err);
|
||||
}
|
||||
}
|
||||
|
||||
listeningServer = null;
|
||||
|
||||
try {
|
||||
await destroyDatabase();
|
||||
} catch (err) {
|
||||
|
||||
@@ -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<T>(...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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,36 +165,92 @@ 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)
|
||||
if (this.applySignalingServerStateChange(state, entry.message, payload, timestamp))
|
||||
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';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.message !== 'inbound' && entry.message !== 'outbound')
|
||||
return;
|
||||
|
||||
const direction = entry.message as DebugNetworkMessageDirection;
|
||||
const type = this.getPayloadString(payload, 'type') ?? 'unknown';
|
||||
|
||||
this.recordSignalingTransportMessage(state, payload, type, direction, timestamp, entry.count);
|
||||
|
||||
switch (type) {
|
||||
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':
|
||||
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':
|
||||
this.applySignalingUserActivityMessage(state, type, payload, timestamp);
|
||||
return;
|
||||
case 'typing':
|
||||
this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
|
||||
return;
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate':
|
||||
this.applySignalingPeerHandshakeMessage(state, type, direction, payload, timestamp, entry.count);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private applySignalingServerStateChange(
|
||||
state: DebugNetworkBuildState,
|
||||
message: string,
|
||||
payload: Record<string, unknown> | 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<string, unknown> | null,
|
||||
type: string,
|
||||
direction: DebugNetworkMessageDirection,
|
||||
timestamp: number,
|
||||
count: number
|
||||
): void {
|
||||
const url = this.getPayloadString(payload, 'url');
|
||||
|
||||
if (url) {
|
||||
if (!url)
|
||||
return;
|
||||
|
||||
const signalingNode = this.ensureSignalingServerNode(state, url, timestamp);
|
||||
const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp);
|
||||
|
||||
@@ -196,32 +259,41 @@ class DebugNetworkSnapshotBuilder {
|
||||
if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected')
|
||||
signalingEdge.stateLabel = 'active';
|
||||
|
||||
this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, entry.count);
|
||||
this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, count);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'identify': {
|
||||
private applySignalingIdentifyMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
direction: DebugNetworkMessageDirection,
|
||||
payload: Record<string, unknown> | null,
|
||||
timestamp: number
|
||||
): void {
|
||||
if (direction !== 'outbound')
|
||||
return;
|
||||
|
||||
const oderId = this.getPayloadString(payload, 'oderId');
|
||||
const displayName = this.getPayloadString(payload, 'displayName');
|
||||
|
||||
if (direction === 'outbound')
|
||||
this.ensureLocalNetworkNode(state, timestamp, oderId, displayName);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'connected': {
|
||||
private applySignalingConnectedMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
payload: Record<string, unknown> | null,
|
||||
timestamp: number
|
||||
): void {
|
||||
const oderId = this.getPayloadString(payload, 'oderId');
|
||||
|
||||
if (oderId)
|
||||
this.ensureLocalNetworkNode(state, timestamp, oderId);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'join_server':
|
||||
case 'view_server':
|
||||
case 'leave_server': {
|
||||
private applySignalingMembershipMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
type: 'join_server' | 'view_server' | 'leave_server',
|
||||
payload: Record<string, unknown> | null,
|
||||
timestamp: number
|
||||
): void {
|
||||
const serverId = this.getPayloadString(payload, 'serverId');
|
||||
|
||||
if (!serverId)
|
||||
@@ -234,11 +306,13 @@ class DebugNetworkSnapshotBuilder {
|
||||
membershipEdge.stateLabel = type === 'view_server'
|
||||
? 'viewing'
|
||||
: (type === 'join_server' ? 'joined' : 'left');
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'server_users': {
|
||||
private applySignalingServerUsersMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
payload: Record<string, unknown> | null,
|
||||
timestamp: number
|
||||
): void {
|
||||
const serverId = this.getPayloadString(payload, 'serverId');
|
||||
const users = this.getPayloadArray(payload, 'users');
|
||||
|
||||
@@ -265,13 +339,14 @@ class DebugNetworkSnapshotBuilder {
|
||||
membershipEdge.isActive = true;
|
||||
membershipEdge.stateLabel = 'joined';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'user_joined':
|
||||
case 'user_left':
|
||||
case 'user_typing': {
|
||||
private applySignalingUserActivityMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
type: 'user_joined' | 'user_left' | 'user_typing',
|
||||
payload: Record<string, unknown> | null,
|
||||
timestamp: number
|
||||
): void {
|
||||
const oderId = this.getPayloadString(payload, 'oderId');
|
||||
const displayName = this.getPayloadString(payload, 'displayName');
|
||||
const serverId = this.getPayloadString(payload, 'serverId');
|
||||
@@ -284,7 +359,9 @@ class DebugNetworkSnapshotBuilder {
|
||||
if (type === 'user_typing')
|
||||
clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
|
||||
|
||||
if (serverId) {
|
||||
if (!serverId)
|
||||
return;
|
||||
|
||||
const serverNode = this.ensureAppServerNode(state, serverId, timestamp);
|
||||
const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp);
|
||||
|
||||
@@ -292,16 +369,14 @@ class DebugNetworkSnapshotBuilder {
|
||||
membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'typing':
|
||||
this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS;
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate': {
|
||||
private applySignalingPeerHandshakeMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
type: 'offer' | 'answer' | 'ice_candidate',
|
||||
direction: DebugNetworkMessageDirection,
|
||||
payload: Record<string, unknown> | 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'));
|
||||
@@ -313,28 +388,50 @@ class DebugNetworkSnapshotBuilder {
|
||||
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);
|
||||
this.incrementHandshakeStats(peerNode, type, direction, count);
|
||||
this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, count);
|
||||
peerEdge.isActive = true;
|
||||
|
||||
if (peerEdge.stateLabel !== 'connected')
|
||||
peerEdge.stateLabel = 'negotiating';
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyDataChannelNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
||||
const payload = this.getEntryPayloadRecord(entry.payload);
|
||||
const timestamp = entry.timestamp;
|
||||
|
||||
if (entry.message === 'Peer latency updated') {
|
||||
if (this.applyDataChannelLatencyMessage(state, entry.message, payload, timestamp))
|
||||
return;
|
||||
|
||||
if (this.applyDataChannelStateMessage(state, entry.message, payload, timestamp))
|
||||
return;
|
||||
|
||||
if (entry.message !== 'inbound' && entry.message !== 'outbound')
|
||||
return;
|
||||
|
||||
this.applyDataChannelPayloadMessage(
|
||||
state,
|
||||
payload,
|
||||
entry.message as DebugNetworkMessageDirection,
|
||||
timestamp,
|
||||
entry.count
|
||||
);
|
||||
}
|
||||
|
||||
private applyDataChannelLatencyMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
message: string,
|
||||
payload: Record<string, unknown> | 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;
|
||||
return true;
|
||||
|
||||
const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp);
|
||||
const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp);
|
||||
@@ -344,37 +441,48 @@ class DebugNetworkSnapshotBuilder {
|
||||
edge.stateLabel = 'connected';
|
||||
peerNode.pingMs = latencyMs;
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.message === 'Data channel open' || entry.message === 'Data channel closed' || entry.message === 'Data channel error') {
|
||||
private applyDataChannelStateMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
message: string,
|
||||
payload: Record<string, unknown> | 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;
|
||||
return true;
|
||||
|
||||
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') {
|
||||
if (message === 'Data channel open') {
|
||||
edge.isActive = true;
|
||||
edge.stateLabel = 'connected';
|
||||
} else if (entry.message === 'Data channel closed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
edge.isActive = false;
|
||||
edge.stateLabel = 'closed';
|
||||
edge.stateLabel = message === 'Data channel closed' ? 'closed' : 'error';
|
||||
|
||||
if (message === 'Data channel closed')
|
||||
peerNode.isSpeaking = false;
|
||||
} else {
|
||||
edge.isActive = false;
|
||||
edge.stateLabel = 'error';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.message !== 'inbound' && entry.message !== 'outbound')
|
||||
return;
|
||||
|
||||
const direction = entry.message as DebugNetworkMessageDirection;
|
||||
private applyDataChannelPayloadMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
payload: Record<string, unknown> | 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,6 +520,21 @@ class DebugNetworkSnapshotBuilder {
|
||||
}
|
||||
|
||||
if (type === 'voice-state') {
|
||||
this.applyVoiceStateDataChannelMessage(state, payload, direction, peerNode, timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'screen-state' || type === 'camera-state')
|
||||
this.applyStreamingStateDataChannelMessage(state, type, payload, direction, peerNode, timestamp);
|
||||
}
|
||||
|
||||
private applyVoiceStateDataChannelMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
payload: Record<string, unknown> | null,
|
||||
direction: DebugNetworkMessageDirection,
|
||||
peerNode: MutableDebugNetworkNode,
|
||||
timestamp: number
|
||||
): void {
|
||||
const voiceState = this.getPayloadRecord(payload, 'voiceState');
|
||||
const subjectNode = direction === 'outbound'
|
||||
? this.ensureLocalNetworkNode(
|
||||
@@ -426,16 +549,24 @@ class DebugNetworkSnapshotBuilder {
|
||||
|
||||
const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId');
|
||||
|
||||
if (serverId) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'screen-state' || type === 'camera-state') {
|
||||
private applyStreamingStateDataChannelMessage(
|
||||
state: DebugNetworkBuildState,
|
||||
type: 'screen-state' | 'camera-state',
|
||||
payload: Record<string, unknown> | null,
|
||||
direction: DebugNetworkMessageDirection,
|
||||
peerNode: MutableDebugNetworkNode,
|
||||
timestamp: number
|
||||
): void {
|
||||
const subjectNode = direction === 'outbound'
|
||||
? this.ensureLocalNetworkNode(
|
||||
state,
|
||||
@@ -448,14 +579,14 @@ class DebugNetworkSnapshotBuilder {
|
||||
? this.getPayloadBoolean(payload, 'isScreenSharing')
|
||||
: this.getPayloadBoolean(payload, 'isCameraEnabled');
|
||||
|
||||
if (isStreaming !== null) {
|
||||
if (isStreaming === null)
|
||||
return;
|
||||
|
||||
subjectNode.isStreaming = isStreaming;
|
||||
|
||||
if (!isStreaming)
|
||||
subjectNode.streams.video = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyGenericWebRTCNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
||||
const payload = this.getEntryPayloadRecord(entry.payload);
|
||||
@@ -471,14 +602,35 @@ class DebugNetworkSnapshotBuilder {
|
||||
switch (entry.message) {
|
||||
case 'Creating peer connection':
|
||||
case 'Received data channel':
|
||||
this.markPeerEdgeConnecting(edge);
|
||||
return;
|
||||
|
||||
case 'connectionstatechange':
|
||||
this.applyGenericConnectionStateMessage(peerNode, edge, payload);
|
||||
return;
|
||||
|
||||
case 'Remote stream updated':
|
||||
this.applyGenericRemoteStreamMessage(peerNode, edge, payload);
|
||||
return;
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
case 'connectionstatechange': {
|
||||
private applyGenericConnectionStateMessage(
|
||||
peerNode: MutableDebugNetworkNode,
|
||||
edge: MutableDebugNetworkEdge,
|
||||
payload: Record<string, unknown> | null
|
||||
): void {
|
||||
const connectionState = this.getStringProperty(payload, 'state');
|
||||
|
||||
if (!connectionState)
|
||||
@@ -489,22 +641,26 @@ class DebugNetworkSnapshotBuilder {
|
||||
edge.stateLabel = connectionState;
|
||||
edge.isActive = connectionState === 'connected' || connectionState === 'connecting';
|
||||
|
||||
if (!edge.isActive) {
|
||||
if (edge.isActive)
|
||||
return;
|
||||
|
||||
peerNode.isSpeaking = false;
|
||||
|
||||
if (connectionState === 'disconnected' || connectionState === 'failed') {
|
||||
if (connectionState !== 'disconnected' && connectionState !== 'failed')
|
||||
return;
|
||||
|
||||
if (previousState !== connectionState)
|
||||
peerNode.connectionDrops += 1;
|
||||
|
||||
peerNode.streams.audio = 0;
|
||||
peerNode.streams.video = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
case 'Remote stream updated': {
|
||||
private applyGenericRemoteStreamMessage(
|
||||
peerNode: MutableDebugNetworkNode,
|
||||
edge: MutableDebugNetworkEdge,
|
||||
payload: Record<string, unknown> | null
|
||||
): void {
|
||||
const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount');
|
||||
const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount');
|
||||
|
||||
@@ -518,11 +674,14 @@ class DebugNetworkSnapshotBuilder {
|
||||
|
||||
if (videoTrackCount !== null)
|
||||
peerNode.streams.video = Math.max(0, Math.round(videoTrackCount));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
case 'Peer transport stats': {
|
||||
private applyGenericTransportStatsMessage(
|
||||
peerNode: MutableDebugNetworkNode,
|
||||
edge: MutableDebugNetworkEdge,
|
||||
payload: Record<string, unknown> | null,
|
||||
timestamp: number
|
||||
): void {
|
||||
edge.isActive = true;
|
||||
|
||||
if (!edge.stateLabel || edge.stateLabel === 'negotiating')
|
||||
@@ -532,10 +691,6 @@ class DebugNetworkSnapshotBuilder {
|
||||
audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'),
|
||||
videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps')
|
||||
}, timestamp);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyVoiceActivityNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void {
|
||||
@@ -558,12 +713,31 @@ class DebugNetworkSnapshotBuilder {
|
||||
const localVoiceServerId = localUser ? this.getUserVoiceServerId(localUser) : null;
|
||||
const localVoiceConnected = localUser?.voiceState?.isConnected === true;
|
||||
|
||||
if (localUser && localIdentity) {
|
||||
this.applyLiveLocalUserState(state, localUser, localIdentity, localVoiceServerId, localVoiceConnected, now);
|
||||
|
||||
for (const user of state.users) {
|
||||
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) {
|
||||
if (!localVoiceServerId)
|
||||
return;
|
||||
|
||||
const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now);
|
||||
const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now);
|
||||
|
||||
@@ -571,13 +745,18 @@ class DebugNetworkSnapshotBuilder {
|
||||
membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing';
|
||||
membershipEdge.lastSeen = now;
|
||||
}
|
||||
}
|
||||
|
||||
for (const user of state.users) {
|
||||
private applyLiveRemoteUserState(
|
||||
state: DebugNetworkBuildState,
|
||||
user: User,
|
||||
localVoiceConnected: boolean,
|
||||
localVoiceServerId: string | null,
|
||||
now: number
|
||||
): void {
|
||||
const identity = this.getUserNetworkIdentity(user);
|
||||
|
||||
if (!identity)
|
||||
continue;
|
||||
return;
|
||||
|
||||
const node = this.isLocalIdentity(state, identity)
|
||||
? this.ensureLocalNetworkNode(state, now, identity, user.displayName)
|
||||
@@ -587,25 +766,19 @@ class DebugNetworkSnapshotBuilder {
|
||||
|
||||
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;
|
||||
}
|
||||
this.syncLiveUserMembershipEdge(state, node, voiceServerId, user.voiceState?.isConnected === true, now);
|
||||
|
||||
if (
|
||||
!this.isLocalIdentity(state, identity)
|
||||
&& localVoiceConnected
|
||||
&& user.voiceState?.isConnected === true
|
||||
&& voiceServerId
|
||||
&& localVoiceServerId
|
||||
&& voiceServerId === localVoiceServerId
|
||||
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;
|
||||
@@ -615,7 +788,25 @@ class DebugNetworkSnapshotBuilder {
|
||||
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 {
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof setInterval> | 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() || '?' }}
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card"
|
||||
[class]="currentStatusColor()"
|
||||
|
||||
@@ -12,7 +12,7 @@ export class LinkMetadataService {
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
extractUrls(content: string): string[] {
|
||||
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
|
||||
return [...content.matchAll(URL_PATTERN)].map((match) => match[0]);
|
||||
}
|
||||
|
||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||
|
||||
@@ -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',
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
cs: 'csharp',
|
||||
@@ -0,0 +1,11 @@
|
||||
@if (videoId()) {
|
||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
||||
<iframe
|
||||
[src]="embedUrl()"
|
||||
class="aspect-video w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
@@ -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()) {
|
||||
<div class="mt-2 w-[480px] max-w-full overflow-hidden rounded-md border border-border/60">
|
||||
<iframe
|
||||
[src]="embedUrl()"
|
||||
class="aspect-video w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
templateUrl: './chat-youtube-embed.component.html'
|
||||
})
|
||||
export class ChatYoutubeEmbedComponent {
|
||||
readonly url = input.required<string>();
|
||||
@@ -44,7 +33,7 @@ export class ChatYoutubeEmbedComponent {
|
||||
);
|
||||
});
|
||||
|
||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
}
|
||||
|
||||
export function isYoutubeUrl(url?: string): boolean {
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
lucideMessageSquareText,
|
||||
lucideMoonStar
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
import type { Room } from '../../../../shared-kernel';
|
||||
import { NotificationsFacade } from '../../application/facades/notifications.facade';
|
||||
import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors';
|
||||
import type { Room } from '../../../../../shared-kernel';
|
||||
import { NotificationsFacade } from '../../../application/facades/notifications.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notifications-settings',
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './application/facades/notifications.facade';
|
||||
export * from './application/effects/notifications.effects';
|
||||
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';
|
||||
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings/notifications-settings.component';
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ThemeGridEditorItem,
|
||||
ThemeGridRect,
|
||||
ThemeLayoutContainerDefinition
|
||||
} from '../../domain/models/theme.model';
|
||||
} from '../../../domain/models/theme.model';
|
||||
|
||||
type DragMode = 'move' | 'resize';
|
||||
|
||||
@@ -8,26 +8,26 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { SettingsModalService } from '../../../../../core/services/settings-modal.service';
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
ThemeElementStyleProperty,
|
||||
ThemeRegistryEntry
|
||||
} from '../../domain/models/theme.model';
|
||||
} from '../../../domain/models/theme.model';
|
||||
import {
|
||||
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
||||
THEME_ELEMENT_STYLE_FIELDS,
|
||||
createAnimationStarterDefinition,
|
||||
getSuggestedFieldDefault
|
||||
} from '../../domain/logic/theme-schema.logic';
|
||||
import { ElementPickerService } from '../../application/services/element-picker.service';
|
||||
import { LayoutSyncService } from '../../application/services/layout-sync.service';
|
||||
import { ThemeLibraryService } from '../../application/services/theme-library.service';
|
||||
import { ThemeRegistryService } from '../../application/services/theme-registry.service';
|
||||
import { ThemeService } from '../../application/services/theme.service';
|
||||
import { THEME_LLM_GUIDE } from '../../domain/constants/theme-llm-guide.constants';
|
||||
import { ThemeGridEditorComponent } from './theme-grid-editor.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';
|
||||
@@ -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',
|
||||
@@ -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';
|
||||
|
||||
@@ -52,16 +52,13 @@ export class VoiceWorkspaceService {
|
||||
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
effect(() => {
|
||||
if (this.voiceSession.voiceSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
open(
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
/>
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
[ngSrc]="voiceSession()?.serverIcon || ''"
|
||||
class="w-5 h-5 rounded object-cover"
|
||||
alt=""
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
} @else {
|
||||
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
<!-- Ping latency indicator -->
|
||||
@@ -246,7 +238,11 @@
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||
(keydown.enter)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||
(keydown.space)="openProfileCard($event, currentUser()!, true); $event.preventDefault(); $event.stopPropagation()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
@@ -293,8 +289,12 @@
|
||||
@for (user of onlineRoomUsers(); track user.id) {
|
||||
<div
|
||||
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(contextmenu)="openUserContextMenu($event, user)"
|
||||
(click)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||
(keydown.enter)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||
(keydown.space)="openProfileCard($event, user, false); $event.preventDefault(); $event.stopPropagation()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="user.displayName"
|
||||
@@ -352,7 +352,11 @@
|
||||
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||
(keydown.enter)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||
(keydown.space)="openProfileCardForMember($event, member); $event.preventDefault(); $event.stopPropagation()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="member.displayName"
|
||||
|
||||
@@ -103,7 +103,7 @@ export class RoomsSidePanelComponent {
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
readonly showVoiceControls = input(true);
|
||||
@@ -186,14 +186,14 @@ export class RoomsSidePanelComponent {
|
||||
draggedVoiceUserId = signal<string | null>(null);
|
||||
dragTargetVoiceChannelId = signal<string | null>(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();
|
||||
|
||||
|
||||
@@ -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,8 +86,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
void video.play().catch(() => {});
|
||||
});
|
||||
|
||||
effect(
|
||||
() => {
|
||||
effect(() => {
|
||||
this.workspacePlayback.settings();
|
||||
|
||||
const item = this.item();
|
||||
@@ -100,9 +99,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
|
||||
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
|
||||
this.muted.set(this.workspacePlayback.isUserMuted(item.peerKey));
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const ref = this.videoRef();
|
||||
@@ -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,8 +456,7 @@ export class VoiceWorkspaceComponent {
|
||||
this.pruneObservedRemoteStreams(peerKeys);
|
||||
});
|
||||
|
||||
effect(
|
||||
() => {
|
||||
effect(() => {
|
||||
const isExpanded = this.showExpanded();
|
||||
const shouldAutoHideChrome = this.shouldAutoHideChrome();
|
||||
|
||||
@@ -485,9 +484,7 @@ export class VoiceWorkspaceComponent {
|
||||
if (shouldRevealChrome) {
|
||||
this.revealWorkspaceChrome();
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onWorkspacePointerMove(): void {
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
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') }
|
||||
);
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="flex items-center gap-1">
|
||||
@if (canKickMembers(member)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickMember(member)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Kick"
|
||||
@@ -37,6 +38,7 @@
|
||||
}
|
||||
@if (canBanMembers(member)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="banMember(member)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Ban"
|
||||
|
||||
@@ -32,7 +32,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { Room, UserRole } from '../../../shared-kernel';
|
||||
import { NotificationsSettingsComponent } from '../../../domains/notifications/feature/settings/notifications-settings.component';
|
||||
import { NotificationsSettingsComponent } from '../../../domains/notifications';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
|
||||
|
||||
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface ThirdPartyLicense {
|
||||
}
|
||||
|
||||
const toLicenseText = (lines: readonly string[]): string => lines.join('\n');
|
||||
const GROUPED_LICENSE_NOTE = 'Grouped by the license declared in the installed package metadata for the packages below. Some upstream packages include their own copyright notices in addition to this standard license text.';
|
||||
const GROUPED_LICENSE_NOTE = 'Grouped by the license declared in the installed package metadata for the packages below. '
|
||||
+ 'Some upstream packages include their own copyright notices in addition to this standard license text.';
|
||||
const MIT_LICENSE_TEXT = toLicenseText([
|
||||
'MIT License',
|
||||
'',
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
||||
import { ContextMenuComponent } from '../../shared';
|
||||
import type { ContextMenuParams } from '../../core/platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ContextMenuComponent } from '../../../shared';
|
||||
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-native-context-menu',
|
||||
@@ -26,18 +26,18 @@ import {
|
||||
selectVoiceChannels,
|
||||
selectIsSignalServerReconnecting,
|
||||
selectSignalServerCompatibilityError
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../shared';
|
||||
import { Room } from '../../shared-kernel';
|
||||
import { VoiceWorkspaceService } from '../../domains/voice-session';
|
||||
import { ThemeNodeDirective } from '../../domains/theme';
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { PlatformService } from '../../../core/platform';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../../shared';
|
||||
import { Room } from '../../../shared-kernel';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable complexity */
|
||||
import {
|
||||
SIGNALING_TYPE_ANSWER,
|
||||
SIGNALING_TYPE_OFFER,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
PeerConnectionManagerContext,
|
||||
PeerConnectionManagerState
|
||||
} from '../shared';
|
||||
import type { PeerData } from '../../realtime.types';
|
||||
|
||||
/**
|
||||
* Queue a negotiation task so SDP operations for a single peer never overlap.
|
||||
@@ -95,6 +95,97 @@ function replaceUnusablePeer(
|
||||
state.peerNegotiationQueue.delete(peerId);
|
||||
}
|
||||
|
||||
function getOrCreatePeerForOffer(
|
||||
state: PeerConnectionManagerState,
|
||||
fromUserId: string,
|
||||
handlers: NegotiationHandlers
|
||||
): PeerData {
|
||||
return state.activePeerConnections.get(fromUserId) ?? handlers.createPeerConnection(fromUserId, false);
|
||||
}
|
||||
|
||||
async function resolveOfferCollision(
|
||||
peerData: PeerData,
|
||||
callbacks: PeerConnectionManagerContext['callbacks'],
|
||||
logger: PeerConnectionManagerContext['logger'],
|
||||
fromUserId: string
|
||||
): Promise<boolean> {
|
||||
const signalingState = peerData.connection.signalingState;
|
||||
const hasCollision = signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
|
||||
|
||||
if (!hasCollision)
|
||||
return true;
|
||||
|
||||
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
||||
const isPolite = !localOderId || localOderId > fromUserId;
|
||||
|
||||
if (!isPolite) {
|
||||
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('Rolling back local offer (polite side)', { fromUserId, localOderId });
|
||||
|
||||
await peerData.connection.setLocalDescription({
|
||||
type: 'rollback'
|
||||
} as RTCSessionDescriptionInit);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncPeerSendersFromTransceivers(peerData: PeerData): void {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
|
||||
for (const transceiver of transceivers) {
|
||||
const receiverKind = transceiver.receiver.track?.kind;
|
||||
|
||||
if (receiverKind === TRACK_KIND_AUDIO) {
|
||||
if (!peerData.audioSender) {
|
||||
peerData.audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (receiverKind === TRACK_KIND_VIDEO && !peerData.videoSender) {
|
||||
peerData.videoSender = transceiver.sender;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function attachAnswererLocalTracks(
|
||||
peerData: PeerData,
|
||||
localStream: MediaStream | null,
|
||||
logger: PeerConnectionManagerContext['logger'],
|
||||
fromUserId: string
|
||||
): Promise<void> {
|
||||
if (!localStream)
|
||||
return;
|
||||
|
||||
logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
||||
|
||||
for (const track of localStream.getTracks()) {
|
||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||
await peerData.audioSender.replaceTrack(track);
|
||||
logger.info('audio replaceTrack (answerer) ok', { fromUserId });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||
await peerData.videoSender.replaceTrack(track);
|
||||
logger.info('video replaceTrack (answerer) ok', { fromUserId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPendingIceCandidates(peerData: PeerData): Promise<void> {
|
||||
for (const candidate of peerData.pendingIceCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
|
||||
peerData.pendingIceCandidates = [];
|
||||
}
|
||||
|
||||
export async function doHandleOffer(
|
||||
context: PeerConnectionManagerContext,
|
||||
fromUserId: string,
|
||||
@@ -107,72 +198,18 @@ export async function doHandleOffer(
|
||||
|
||||
replaceUnusablePeer(context, fromUserId, 'incoming offer');
|
||||
|
||||
let peerData = state.activePeerConnections.get(fromUserId);
|
||||
|
||||
if (!peerData) {
|
||||
peerData = handlers.createPeerConnection(fromUserId, false);
|
||||
}
|
||||
const peerData = getOrCreatePeerForOffer(state, fromUserId, handlers);
|
||||
|
||||
try {
|
||||
const signalingState = peerData.connection.signalingState;
|
||||
const hasCollision =
|
||||
signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
|
||||
const shouldApplyOffer = await resolveOfferCollision(peerData, callbacks, logger, fromUserId);
|
||||
|
||||
if (hasCollision) {
|
||||
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
||||
const isPolite = !localOderId || localOderId > fromUserId;
|
||||
|
||||
if (!isPolite) {
|
||||
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
|
||||
if (!shouldApplyOffer)
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Rolling back local offer (polite side)', { fromUserId, localOderId });
|
||||
|
||||
await peerData.connection.setLocalDescription({
|
||||
type: 'rollback'
|
||||
} as RTCSessionDescriptionInit);
|
||||
}
|
||||
|
||||
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
|
||||
for (const transceiver of transceivers) {
|
||||
const receiverKind = transceiver.receiver.track?.kind;
|
||||
|
||||
if (receiverKind === TRACK_KIND_AUDIO) {
|
||||
if (!peerData.audioSender) {
|
||||
peerData.audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
} else if (receiverKind === TRACK_KIND_VIDEO && !peerData.videoSender) {
|
||||
peerData.videoSender = transceiver.sender;
|
||||
}
|
||||
}
|
||||
|
||||
const localStream = callbacks.getLocalMediaStream();
|
||||
|
||||
if (localStream) {
|
||||
logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
||||
|
||||
for (const track of localStream.getTracks()) {
|
||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||
await peerData.audioSender.replaceTrack(track);
|
||||
logger.info('audio replaceTrack (answerer) ok', { fromUserId });
|
||||
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||
await peerData.videoSender.replaceTrack(track);
|
||||
logger.info('video replaceTrack (answerer) ok', { fromUserId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of peerData.pendingIceCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
|
||||
peerData.pendingIceCandidates = [];
|
||||
syncPeerSendersFromTransceivers(peerData);
|
||||
await attachAnswererLocalTracks(peerData, callbacks.getLocalMediaStream(), logger, fromUserId);
|
||||
await applyPendingIceCandidates(peerData);
|
||||
|
||||
const answer = await peerData.connection.createAnswer();
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ import {
|
||||
})
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
export class ContextMenuComponent implements OnInit, AfterViewInit {
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
x = input.required<number>();
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
y = input.required<number>();
|
||||
width = input<string>('w-48');
|
||||
widthPx = input<number | null>(null);
|
||||
|
||||
@@ -31,7 +31,7 @@ import { type DebugLogEntry, type DebugLogLevel } from '../../../../core/service
|
||||
export class DebugConsoleEntryListComponent {
|
||||
readonly entries = input.required<DebugLogEntry[]>();
|
||||
readonly autoScroll = input.required<boolean>();
|
||||
readonly entryExpanded = output<void>();
|
||||
readonly entryExpanded = output();
|
||||
readonly expandedEntryIds = signal<number[]>([]);
|
||||
|
||||
private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('viewport');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable id-denylist, id-length, padding-line-between-statements */
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
|
||||
@@ -46,6 +46,8 @@ type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact';
|
||||
}
|
||||
})
|
||||
export class DebugConsoleComponent {
|
||||
private static readonly VISIBLE_ENTRY_LIMIT = 500;
|
||||
|
||||
readonly debugging = inject(DebuggingService);
|
||||
readonly resizeService = inject(DebugConsoleResizeService);
|
||||
readonly exportService = inject(DebugConsoleExportService);
|
||||
@@ -78,7 +80,6 @@ export class DebugConsoleComponent {
|
||||
readonly sourceOptions = computed(() => {
|
||||
return Array.from(new Set(this.entries().map((entry) => entry.source))).sort();
|
||||
});
|
||||
private static readonly VISIBLE_ENTRY_LIMIT = 500;
|
||||
|
||||
readonly filteredEntries = computed(() => {
|
||||
const searchTerm = this.searchTerm().trim()
|
||||
@@ -245,7 +246,7 @@ export class DebugConsoleComponent {
|
||||
}
|
||||
|
||||
toggleShowAllEntries(): void {
|
||||
this.showAllEntries.update((v) => !v);
|
||||
this.showAllEntries.update((isVisible) => !isVisible);
|
||||
}
|
||||
|
||||
startTopResize(event: MouseEvent): void {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<div
|
||||
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||
>
|
||||
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||
|
||||
<div class="relative px-4">
|
||||
<div class="-mt-9">
|
||||
<app-user-avatar
|
||||
[name]="user().displayName"
|
||||
[avatarUrl]="user().avatarUrl"
|
||||
size="xl"
|
||||
[status]="user().status"
|
||||
[showStatusBadge]="true"
|
||||
ringClass="ring-4 ring-card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-4 pt-3">
|
||||
<p class="truncate text-base font-semibold text-foreground">{{ user().displayName }}</p>
|
||||
<p class="truncate text-sm text-muted-foreground">{{ user().username }}</p>
|
||||
|
||||
@if (editable()) {
|
||||
<div class="relative mt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-secondary/60"
|
||||
(click)="toggleStatusMenu()"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class]="currentStatusColor()"
|
||||
></span>
|
||||
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
|
||||
<ng-icon
|
||||
name="lucideChevronDown"
|
||||
class="h-3 w-3 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (showStatusMenu()) {
|
||||
<div class="absolute bottom-full left-0 z-10 mb-1 w-full rounded-md border border-border bg-card py-1 shadow-lg">
|
||||
@for (opt of statusOptions; track opt.label) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-secondary"
|
||||
(click)="setStatus(opt.value)"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class]="opt.color"
|
||||
></span>
|
||||
<span>{{ opt.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class]="currentStatusColor()"
|
||||
></span>
|
||||
<span>{{ currentStatusLabel() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,80 +19,12 @@ import { User, UserStatus } from '../../../shared-kernel';
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideChevronDown })],
|
||||
template: `
|
||||
<div
|
||||
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||
>
|
||||
<!-- Banner -->
|
||||
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
|
||||
|
||||
<!-- Avatar (overlapping banner) -->
|
||||
<div class="relative px-4">
|
||||
<div class="-mt-9">
|
||||
<app-user-avatar
|
||||
[name]="user().displayName"
|
||||
[avatarUrl]="user().avatarUrl"
|
||||
size="xl"
|
||||
[status]="user().status"
|
||||
[showStatusBadge]="true"
|
||||
ringClass="ring-4 ring-card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="px-5 pt-3 pb-4">
|
||||
<p class="text-base font-semibold text-foreground truncate">{{ user().displayName }}</p>
|
||||
<p class="text-sm text-muted-foreground truncate">{{ user().username }}</p>
|
||||
|
||||
@if (editable()) {
|
||||
<!-- Status picker -->
|
||||
<div class="relative mt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-secondary/60 transition-colors"
|
||||
(click)="toggleStatusMenu()"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full" [class]="currentStatusColor()"></span>
|
||||
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
|
||||
<ng-icon
|
||||
name="lucideChevronDown"
|
||||
class="w-3 h-3 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
@if (showStatusMenu()) {
|
||||
<div class="absolute left-0 bottom-full mb-1 w-full bg-card border border-border rounded-md shadow-lg py-1 z-10">
|
||||
@for (opt of statusOptions; track opt.label) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-left text-xs hover:bg-secondary flex items-center gap-2"
|
||||
(click)="setStatus(opt.value)"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full" [class]="opt.color"></span>
|
||||
<span>{{ opt.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span class="w-2 h-2 rounded-full" [class]="currentStatusColor()"></span>
|
||||
<span>{{ currentStatusLabel() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
templateUrl: './profile-card.component.html'
|
||||
})
|
||||
export class ProfileCardComponent {
|
||||
user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
|
||||
editable = signal(false);
|
||||
|
||||
private userStatus = inject(UserStatusService);
|
||||
showStatusMenu = signal(false);
|
||||
readonly user = signal<User>({ 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 {
|
||||
|
||||
@@ -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<number>();
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
y = input.required<number>();
|
||||
peerId = input.required<string>();
|
||||
displayName = input.required<string>();
|
||||
|
||||
Reference in New Issue
Block a user