7 Commits

Author SHA1 Message Date
Myx
0865c2fe33 feat: Basic general context menu
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Deploy Web Apps / deploy (push) Successful in 14m39s
Queue Release Build / build-linux (push) Successful in 40m59s
Queue Release Build / build-windows (push) Successful in 28m59s
Queue Release Build / finalize (push) Successful in 1m58s
2026-04-04 05:38:05 +02:00
Myx
4a41de79d6 fix: debugger lagging from too many logs 2026-04-04 04:55:13 +02:00
Myx
84fa45985a feat: Add chat embeds v1
Youtube and Website metadata embeds
2026-04-04 04:47:04 +02:00
Myx
35352923a5 feat: Youtube embed support 2026-04-04 03:30:21 +02:00
Myx
b9df9c92f2 fix: links not getting recognised in chat 2026-04-04 03:14:25 +02:00
Myx
8674579b19 fix: leave and reconnect sound randomly playing, also fix leave sound when muting 2026-04-04 03:09:44 +02:00
Myx
de2d3300d4 fix: Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Server:
- Close stale WebSocket connections sharing the same oderId in
  handleIdentify instead of letting them linger up to 45s
- Make user_joined/user_left broadcasts identity-aware so duplicate
  sockets don't produce phantom join/leave events
- Include serverIds in user_left payload for multi-room presence
- Simplify findUserByOderId now that stale sockets are cleaned up

Client - signaling:
- Add fallback offer system with 1s timer for missed user_joined races
- Add non-initiator takeover after 5s when the initiator fails to send
  an offer (NON_INITIATOR_GIVE_UP_MS)
- Scope peerServerMap per signaling URL to prevent cross-server
  collisions
- Add socket identity guards on all signaling event handlers
- Replace canReusePeerConnection with hasActivePeerConnection and
  isPeerConnectionNegotiating with extended grace periods

Client - peer connections:
- Extract replaceUnusablePeer helper to deduplicate stale peer
  replacement in offer and ICE handlers
- Add stale connectionstatechange guard to ignore events from replaced
  RTCPeerConnection instances
- Use deterministic initiator election in peer recovery reconnects
- Track createdAt on PeerData for staleness detection

Client - presence:
- Add multi-room presence tracking via presenceServerIds on User
- Replace clearUsers + individual userJoined with syncServerPresence
  for atomic server roster updates
- Make userLeft handle partial server removal instead of full eviction

Documentation:
- Add server-side connection hygiene, non-initiator takeover, and stale
  peer replacement sections to the realtime README
2026-04-04 02:47:58 +02:00
65 changed files with 2320 additions and 209 deletions

View File

@@ -18,7 +18,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
timestamp: message.timestamp, timestamp: message.timestamp,
editedAt: message.editedAt ?? null, editedAt: message.editedAt ?? null,
isDeleted: message.isDeleted ? 1 : 0, isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null replyToId: message.replyToId ?? null,
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
}); });
await repo.save(entity); await repo.save(entity);

View File

@@ -13,29 +13,35 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
if (!existing) if (!existing)
return; return;
if (updates.channelId !== undefined) const directFields = [
existing.channelId = updates.channelId ?? null; 'senderId',
'senderName',
'content',
'timestamp'
] as const;
const entity = existing as unknown as Record<string, unknown>;
if (updates.senderId !== undefined) for (const field of directFields) {
existing.senderId = updates.senderId; if (updates[field] !== undefined)
entity[field] = updates[field];
}
if (updates.senderName !== undefined) const nullableFields = [
existing.senderName = updates.senderName; 'channelId',
'editedAt',
'replyToId'
] as const;
if (updates.content !== undefined) for (const field of nullableFields) {
existing.content = updates.content; if (updates[field] !== undefined)
entity[field] = updates[field] ?? null;
if (updates.timestamp !== undefined) }
existing.timestamp = updates.timestamp;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.isDeleted !== undefined) if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0; existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined) if (updates.linkMetadata !== undefined)
existing.replyToId = updates.replyToId ?? null; existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
await repo.save(existing); await repo.save(existing);

View File

@@ -35,7 +35,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
editedAt: row.editedAt ?? undefined, editedAt: row.editedAt ?? undefined,
reactions: isDeleted ? [] : reactions, reactions: isDeleted ? [] : reactions,
isDeleted, isDeleted,
replyToId: row.replyToId ?? undefined replyToId: row.replyToId ?? undefined,
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
}; };
} }

View File

@@ -50,6 +50,7 @@ export interface MessagePayload {
reactions?: ReactionPayload[]; reactions?: ReactionPayload[];
isDeleted?: boolean; isDeleted?: boolean;
replyToId?: string; replyToId?: string;
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
} }
export interface ReactionPayload { export interface ReactionPayload {

View File

@@ -35,4 +35,7 @@ export class MessageEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
replyToId!: string | null; replyToId!: string | null;
@Column('text', { nullable: true })
linkMetadata!: string | null;
} }

View File

@@ -4,6 +4,8 @@ import {
desktopCapturer, desktopCapturer,
dialog, dialog,
ipcMain, ipcMain,
nativeImage,
net,
Notification, Notification,
shell shell
} from 'electron'; } from 'electron';
@@ -503,4 +505,34 @@ export function setupSystemHandlers(): void {
await fsp.mkdir(dirPath, { recursive: true }); await fsp.mkdir(dirPath, { recursive: true });
return true; return true;
}); });
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
if (typeof srcURL !== 'string' || !srcURL) {
return false;
}
return new Promise<boolean>((resolve) => {
const request = net.request(srcURL);
request.on('response', (response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
const image = nativeImage.createFromBuffer(Buffer.concat(chunks));
if (!image.isEmpty()) {
clipboard.writeImage(image);
resolve(true);
} else {
resolve(false);
}
});
response.on('error', () => resolve(false));
});
request.on('error', () => resolve(false));
request.end();
});
});
} }

View File

@@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLinkMetadata1000000000005 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "linkMetadata" text`);
}
async down(queryRunner: QueryRunner): Promise<void> {
// SQLite does not support DROP COLUMN; column is nullable and harmless.
}
}

View File

@@ -124,6 +124,22 @@ function readLinuxDisplayServer(): string {
} }
} }
export interface ContextMenuParams {
posX: number;
posY: number;
isEditable: boolean;
selectionText: string;
linkURL: string;
mediaType: string;
srcURL: string;
editFlags: {
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
}
export interface ElectronAPI { export interface ElectronAPI {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -194,6 +210,9 @@ export interface ElectronAPI {
deleteFile: (filePath: string) => Promise<boolean>; deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
command: <T = unknown>(command: Command) => Promise<T>; command: <T = unknown>(command: Command) => Promise<T>;
query: <T = unknown>(query: Query) => Promise<T>; query: <T = unknown>(query: Query) => Promise<T>;
} }
@@ -299,6 +318,19 @@ const electronAPI: ElectronAPI = {
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath), deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
onContextMenu: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => {
listener(params);
};
ipcRenderer.on('show-context-menu', wrappedListener);
return () => {
ipcRenderer.removeListener('show-context-menu', wrappedListener);
};
},
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
command: (command) => ipcRenderer.invoke('cqrs:command', command), command: (command) => ipcRenderer.invoke('cqrs:command', command),
query: (query) => ipcRenderer.invoke('cqrs:query', query) query: (query) => ipcRenderer.invoke('cqrs:query', query)
}; };

View File

@@ -264,6 +264,24 @@ export async function createWindow(): Promise<void> {
emitWindowState(); emitWindowState();
mainWindow.webContents.on('context-menu', (_event, params) => {
mainWindow?.webContents.send('show-context-menu', {
posX: params.x,
posY: params.y,
isEditable: params.isEditable,
selectionText: params.selectionText,
linkURL: params.linkURL,
mediaType: params.mediaType,
srcURL: params.srcURL,
editFlags: {
canCut: params.editFlags.canCut,
canCopy: params.editFlags.canCopy,
canPaste: params.editFlags.canPaste,
canSelectAll: params.editFlags.canSelectAll
}
});
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url); shell.openExternal(url);
return { action: 'deny' }; return { action: 'deny' };

View File

@@ -4,18 +4,28 @@ import { resolveRuntimePath } from '../runtime-paths';
export type ServerHttpProtocol = 'http' | 'https'; export type ServerHttpProtocol = 'http' | 'https';
export interface LinkPreviewConfig {
enabled: boolean;
cacheTtlMinutes: number;
maxCacheSizeMb: number;
}
export interface ServerVariablesConfig { export interface ServerVariablesConfig {
klipyApiKey: string; klipyApiKey: string;
releaseManifestUrl: string; releaseManifestUrl: string;
serverPort: number; serverPort: number;
serverProtocol: ServerHttpProtocol; serverProtocol: ServerHttpProtocol;
serverHost: string; serverHost: string;
linkPreview: LinkPreviewConfig;
} }
const DATA_DIR = resolveRuntimePath('data'); const DATA_DIR = resolveRuntimePath('data');
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json'); const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
const DEFAULT_SERVER_PORT = 3001; const DEFAULT_SERVER_PORT = 3001;
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http'; const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
const DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES = 7200;
const DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB = 50;
const HARD_MAX_CACHE_SIZE_MB = 50;
function normalizeKlipyApiKey(value: unknown): string { function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@@ -66,6 +76,27 @@ function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): nu
: fallback; : fallback;
} }
function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
const raw = (value && typeof value === 'object' && !Array.isArray(value))
? value as Record<string, unknown>
: {};
const enabled = typeof raw.enabled === 'boolean'
? raw.enabled
: true;
const cacheTtl = typeof raw.cacheTtlMinutes === 'number'
&& Number.isFinite(raw.cacheTtlMinutes)
&& raw.cacheTtlMinutes >= 0
? raw.cacheTtlMinutes
: DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES;
const maxSize = typeof raw.maxCacheSizeMb === 'number'
&& Number.isFinite(raw.maxCacheSizeMb)
&& raw.maxCacheSizeMb >= 0
? Math.min(raw.maxCacheSizeMb, HARD_MAX_CACHE_SIZE_MB)
: DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB;
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
}
function hasEnvironmentOverride(value: string | undefined): value is string { function hasEnvironmentOverride(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0; return typeof value === 'string' && value.trim().length > 0;
} }
@@ -111,7 +142,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl), releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
serverPort: normalizeServerPort(remainingParsed.serverPort), serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress) serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
}; };
const nextContents = JSON.stringify(normalized, null, 2) + '\n'; const nextContents = JSON.stringify(normalized, null, 2) + '\n';
@@ -124,7 +156,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
releaseManifestUrl: normalized.releaseManifestUrl, releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort, serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol, serverProtocol: normalized.serverProtocol,
serverHost: normalized.serverHost serverHost: normalized.serverHost,
linkPreview: normalized.linkPreview
}; };
} }
@@ -169,3 +202,7 @@ export function getServerHost(): string | undefined {
export function isHttpsServerEnabled(): boolean { export function isHttpsServerEnabled(): boolean {
return getServerProtocol() === 'https'; return getServerProtocol() === 'https';
} }
export function getLinkPreviewConfig(): LinkPreviewConfig {
return getVariablesConfig().linkPreview;
}

View File

@@ -1,6 +1,7 @@
import { Express } from 'express'; import { Express } from 'express';
import healthRouter from './health'; import healthRouter from './health';
import klipyRouter from './klipy'; import klipyRouter from './klipy';
import linkMetadataRouter from './link-metadata';
import proxyRouter from './proxy'; import proxyRouter from './proxy';
import usersRouter from './users'; import usersRouter from './users';
import serversRouter from './servers'; import serversRouter from './servers';
@@ -10,6 +11,7 @@ import { invitesApiRouter, invitePageRouter } from './invites';
export function registerRoutes(app: Express): void { export function registerRoutes(app: Express): void {
app.use('/api', healthRouter); app.use('/api', healthRouter);
app.use('/api', klipyRouter); app.use('/api', klipyRouter);
app.use('/api', linkMetadataRouter);
app.use('/api', proxyRouter); app.use('/api', proxyRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter); app.use('/api/servers', serversRouter);

View File

@@ -0,0 +1,292 @@
import { Router } from 'express';
import { getLinkPreviewConfig } from '../config/variables';
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
const router = Router();
const REQUEST_TIMEOUT_MS = 8000;
const MAX_HTML_BYTES = 512 * 1024;
const BYTES_PER_MB = 1024 * 1024;
const MAX_FIELD_LENGTH = 512;
interface CachedMetadata {
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
failed?: boolean;
cachedAt: number;
}
const metadataCache = new Map<string, CachedMetadata>();
let cacheByteEstimate = 0;
function estimateEntryBytes(key: string, entry: CachedMetadata): number {
let bytes = key.length * 2;
if (entry.title)
bytes += entry.title.length * 2;
if (entry.description)
bytes += entry.description.length * 2;
if (entry.imageUrl)
bytes += entry.imageUrl.length * 2;
if (entry.siteName)
bytes += entry.siteName.length * 2;
return bytes + 64;
}
function cacheSet(key: string, entry: CachedMetadata): void {
const config = getLinkPreviewConfig();
const maxBytes = config.maxCacheSizeMb * BYTES_PER_MB;
if (metadataCache.has(key)) {
const existing = metadataCache.get(key) as CachedMetadata;
cacheByteEstimate -= estimateEntryBytes(key, existing);
}
const entryBytes = estimateEntryBytes(key, entry);
while (cacheByteEstimate + entryBytes > maxBytes && metadataCache.size > 0) {
const oldest = metadataCache.keys().next().value as string;
const oldestEntry = metadataCache.get(oldest) as CachedMetadata;
cacheByteEstimate -= estimateEntryBytes(oldest, oldestEntry);
metadataCache.delete(oldest);
}
metadataCache.set(key, entry);
cacheByteEstimate += entryBytes;
}
function truncateField(value: string | undefined): string | undefined {
if (!value)
return value;
if (value.length <= MAX_FIELD_LENGTH)
return value;
return value.slice(0, MAX_FIELD_LENGTH);
}
function sanitizeImageUrl(rawUrl: string | undefined, baseUrl: string): string | undefined {
if (!rawUrl)
return undefined;
try {
const resolved = new URL(rawUrl, baseUrl);
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:')
return undefined;
return resolved.href;
} catch {
return undefined;
}
}
function getMetaContent(html: string, patterns: RegExp[]): string | undefined {
for (const pattern of patterns) {
const match = pattern.exec(html);
if (match?.[1])
return decodeHtmlEntities(match[1].trim());
}
return undefined;
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/');
}
function parseMetadata(html: string, url: string): CachedMetadata {
const title = getMetaContent(html, [
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i,
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i,
/<title[^>]*>([^<]+)<\/title>/i
]);
const description = getMetaContent(html, [
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i,
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i,
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i
]);
const rawImageUrl = getMetaContent(html, [
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i
]);
const siteNamePatterns = [
// eslint-disable-next-line @stylistic/js/array-element-newline
/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i
];
const siteName = getMetaContent(html, siteNamePatterns);
const imageUrl = sanitizeImageUrl(rawImageUrl, url);
return {
title: truncateField(title),
description: truncateField(description),
imageUrl,
siteName: truncateField(siteName),
cachedAt: Date.now()
};
}
function evictExpired(): void {
const config = getLinkPreviewConfig();
if (config.cacheTtlMinutes === 0) {
cacheByteEstimate = 0;
metadataCache.clear();
return;
}
const ttlMs = config.cacheTtlMinutes * 60 * 1000;
const now = Date.now();
for (const [key, entry] of metadataCache) {
if (now - entry.cachedAt > ttlMs) {
cacheByteEstimate -= estimateEntryBytes(key, entry);
metadataCache.delete(key);
}
}
}
router.get('/link-metadata', async (req, res) => {
try {
const config = getLinkPreviewConfig();
if (!config.enabled) {
return res.status(403).json({ error: 'Link previews are disabled' });
}
const url = String(req.query.url || '');
if (!/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Invalid URL' });
}
const hostAllowed = await resolveAndValidateHost(url);
if (!hostAllowed) {
return res.status(400).json({ error: 'URL resolves to a blocked address' });
}
evictExpired();
const cached = metadataCache.get(url);
if (cached) {
const { cachedAt, ...metadata } = cached;
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
return res.json(metadata);
}
console.log(`[Link Metadata] Cache miss for ${url}. Fetching...`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await safeFetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/html',
'User-Agent': 'MetoYou-LinkPreview/1.0'
}
});
clearTimeout(timeout);
if (!response || !response.ok) {
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
cacheSet(url, failed);
return res.json({ failed: true });
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
cacheSet(url, failed);
return res.json({ failed: true });
}
const reader = response.body?.getReader();
if (!reader) {
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
cacheSet(url, failed);
return res.json({ failed: true });
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) {
chunks.push(result.value);
totalBytes += result.value.length;
if (totalBytes > MAX_HTML_BYTES) {
reader.cancel();
break;
}
}
}
const html = Buffer.concat(chunks).toString('utf-8');
const metadata = parseMetadata(html, url);
cacheSet(url, metadata);
const { cachedAt, ...result } = metadata;
res.json(result);
} catch (err) {
const url = String(req.query.url || '');
if (url) {
cacheSet(url, { failed: true, cachedAt: Date.now() });
}
if ((err as { name?: string })?.name === 'AbortError') {
return res.json({ failed: true });
}
console.error('Link metadata error:', err);
res.json({ failed: true });
}
});
export default router;

View File

@@ -1,4 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
const router = Router(); const router = Router();
@@ -10,14 +11,20 @@ router.get('/image-proxy', async (req, res) => {
return res.status(400).json({ error: 'Invalid URL' }); return res.status(400).json({ error: 'Invalid URL' });
} }
const hostAllowed = await resolveAndValidateHost(url);
if (!hostAllowed) {
return res.status(400).json({ error: 'URL resolves to a blocked address' });
}
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000); const timeout = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url, { redirect: 'follow', signal: controller.signal }); const response = await safeFetch(url, { signal: controller.signal });
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { if (!response || !response.ok) {
return res.status(response.status).end(); return res.status(response?.status ?? 502).end();
} }
const contentType = response.headers.get('content-type') || ''; const contentType = response.headers.get('content-type') || '';

View File

@@ -0,0 +1,119 @@
import { lookup } from 'dns/promises';
const MAX_REDIRECTS = 5;
function isPrivateIp(ip: string): boolean {
if (
ip === '127.0.0.1' ||
ip === '::1' ||
ip === '0.0.0.0' ||
ip === '::'
)
return true;
// 10.x.x.x
if (ip.startsWith('10.'))
return true;
// 172.16.0.0 - 172.31.255.255
if (ip.startsWith('172.')) {
const second = parseInt(ip.split('.')[1], 10);
if (second >= 16 && second <= 31)
return true;
}
// 192.168.x.x
if (ip.startsWith('192.168.'))
return true;
// 169.254.x.x (link-local, AWS metadata)
if (ip.startsWith('169.254.'))
return true;
// IPv6 private ranges (fc00::/7, fe80::/10)
const lower = ip.toLowerCase();
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
return true;
return false;
}
export async function resolveAndValidateHost(url: string): Promise<boolean> {
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
return false;
}
// Block obvious private hostnames
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
return false;
// If hostname is already an IP literal, check it directly
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
try {
const { address } = await lookup(hostname);
return !isPrivateIp(address);
} catch {
return false;
}
}
export interface SafeFetchOptions {
signal?: AbortSignal;
headers?: Record<string, string>;
}
/**
* Fetches a URL while following redirects safely, validating each
* hop against SSRF (private/reserved IPs, blocked hostnames).
*
* The caller must validate the initial URL with `resolveAndValidateHost`
* before calling this function.
*/
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
let currentUrl = url;
let response: Response | undefined;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
response = await fetch(currentUrl, {
redirect: 'manual',
signal: options.signal,
headers: options.headers
});
const location = response.headers.get('location');
if (response.status >= 300 && response.status < 400 && location) {
let nextUrl: string;
try {
nextUrl = new URL(location, currentUrl).href;
} catch {
break;
}
if (!/^https?:\/\//i.test(nextUrl))
break;
const redirectAllowed = await resolveAndValidateHost(nextUrl);
if (!redirectAllowed)
break;
currentUrl = nextUrl;
continue;
}
break;
}
return response;
}

View File

@@ -1,4 +1,6 @@
import { WebSocket } from 'ws';
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { ConnectedUser } from './types';
interface WsMessage { interface WsMessage {
[key: string]: unknown; [key: string]: unknown;
@@ -24,6 +26,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
} }
} }
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
const usersByOderId = new Map<string, ConnectedUser>();
connectedUsers.forEach((user) => {
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
return;
}
usersByOderId.set(user.oderId, user);
});
return Array.from(usersByOderId.values());
}
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
connectionId !== excludeConnectionId
&& user.oderId === oderId
&& user.serverIds.has(serverId)
&& user.ws.readyState === WebSocket.OPEN
);
}
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
const serverIds = new Set<string>();
connectedUsers.forEach((user, connectionId) => {
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
return;
}
user.serverIds.forEach((serverId) => serverIds.add(serverId));
});
return Array.from(serverIds);
}
export function notifyUser(oderId: string, message: WsMessage): void { export function notifyUser(oderId: string, message: WsMessage): void {
const user = findUserByOderId(oderId); const user = findUserByOderId(oderId);
@@ -33,5 +72,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
} }
export function findUserByOderId(oderId: string) { export function findUserByOderId(oderId: string) {
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId); let match: ConnectedUser | undefined;
connectedUsers.forEach((user) => {
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
match = user;
}
});
return match;
} }

View File

@@ -1,6 +1,12 @@
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { ConnectedUser } from './types'; import { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId } from './broadcast'; import {
broadcastToServer,
findUserByOderId,
getServerIdsForOderId,
getUniqueUsersInServer,
isOderIdConnectedToServer
} from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service'; import { authorizeWebSocketJoin } from '../services/server-access.service';
interface WsMessage { interface WsMessage {
@@ -14,24 +20,53 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
return normalized || fallback; return normalized || fallback;
} }
function readMessageId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
if (!normalized || normalized === 'undefined' || normalized === 'null') {
return undefined;
}
return normalized;
}
/** Sends the current user list for a given server to a single connected user. */ /** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void { function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = Array.from(connectedUsers.values()) const users = getUniqueUsersInServer(serverId, user.oderId)
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) })); .map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
} }
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
user.oderId = String(message['oderId'] || connectionId); const newOderId = readMessageId(message['oderId']) ?? connectionId;
// Close stale connections from the same identity so offer routing
// always targets the freshest socket (e.g. after page refresh).
connectedUsers.forEach((existing, existingId) => {
if (existingId !== connectionId && existing.oderId === newOderId) {
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId})`);
try {
existing.ws.close();
} catch { /* already closing */ }
connectedUsers.delete(existingId);
}
});
user.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
} }
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> { async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const sid = String(message['serverId']); const sid = readMessageId(message['serverId']);
if (!sid) if (!sid)
return; return;
@@ -48,16 +83,20 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
return; return;
} }
const isNew = !user.serverIds.has(sid); const isNewConnectionMembership = !user.serverIds.has(sid);
const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId);
user.serverIds.add(sid); user.serverIds.add(sid);
user.viewedServerId = sid; user.viewedServerId = sid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`); console.log(
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
);
sendServerUsers(user, sid); sendServerUsers(user, sid);
if (isNew) { if (isNewIdentityMembership) {
broadcastToServer(sid, { broadcastToServer(sid, {
type: 'user_joined', type: 'user_joined',
oderId: user.oderId, oderId: user.oderId,
@@ -68,7 +107,10 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
} }
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const viewSid = String(message['serverId']); const viewSid = readMessageId(message['serverId']);
if (!viewSid)
return;
user.viewedServerId = viewSid; user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
@@ -78,7 +120,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
} }
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!leaveSid) if (!leaveSid)
return; return;
@@ -90,17 +132,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
if (remainingServerIds.includes(leaveSid)) {
return;
}
broadcastToServer(leaveSid, { broadcastToServer(leaveSid, {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName), displayName: normalizeDisplayName(user.displayName),
serverId: leaveSid, serverId: leaveSid,
serverIds: Array.from(user.serverIds) serverIds: remainingServerIds
}, user.oderId); }, user.oderId);
} }
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void { function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
const targetUserId = String(message['targetUserId'] || ''); const targetUserId = readMessageId(message['targetUserId']) ?? '';
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`); console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);

View File

@@ -6,7 +6,11 @@ import {
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { broadcastToServer } from './broadcast'; import {
broadcastToServer,
getServerIdsForOderId,
isOderIdConnectedToServer
} from './broadcast';
import { handleWebSocketMessage } from './handler'; import { handleWebSocketMessage } from './handler';
/** How often to ping all connected clients (ms). */ /** How often to ping all connected clients (ms). */
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
if (user) { if (user) {
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`); console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
user.serverIds.forEach((sid) => { user.serverIds.forEach((sid) => {
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
return;
}
broadcastToServer(sid, { broadcastToServer(sid, {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName, displayName: user.displayName,
serverId: sid, serverId: sid,
serverIds: [] serverIds: remainingServerIds
}, user.oderId); }, user.oderId);
}); });

View File

@@ -150,6 +150,7 @@
} }
<app-settings-modal /> <app-settings-modal />
<app-screen-share-source-picker /> <app-screen-share-source-picker />
<app-native-context-menu />
<app-debug-console [showLauncher]="false" /> <app-debug-console [showLauncher]="false" />
<app-theme-picker-overlay /> <app-theme-picker-overlay />
</div> </div>

View File

@@ -39,6 +39,7 @@ import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.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 { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { NativeContextMenuComponent } from './features/shell/native-context-menu.component';
import { UsersActions } from './store/users/users.actions'; import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions'; import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -61,6 +62,7 @@ import {
SettingsModalComponent, SettingsModalComponent,
DebugConsoleComponent, DebugConsoleComponent,
ScreenShareSourcePickerComponent, ScreenShareSourcePickerComponent,
NativeContextMenuComponent,
ThemeNodeDirective, ThemeNodeDirective,
ThemePickerOverlayComponent ThemePickerOverlayComponent
], ],

View File

@@ -10,8 +10,9 @@ export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/; export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
export const STORE_DEVTOOLS_MAX_AGE = 25; export const STORE_DEVTOOLS_MAX_AGE = 25;
export const DEBUG_LOG_MAX_ENTRIES = 500; export const DEBUG_LOG_MAX_ENTRIES = 5000;
export const DEFAULT_MAX_USERS = 50; export const DEFAULT_MAX_USERS = 50;
export const DEFAULT_AUDIO_BITRATE_KBPS = 96; export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
export const DEFAULT_VOLUME = 100; export const DEFAULT_VOLUME = 100;
export const SEARCH_DEBOUNCE_MS = 300; export const SEARCH_DEBOUNCE_MS = 300;
export const RECONNECT_SOUND_GRACE_MS = 15_000;

View File

@@ -134,6 +134,22 @@ export interface ElectronQuery {
payload: unknown; payload: unknown;
} }
export interface ContextMenuParams {
posX: number;
posY: number;
isEditable: boolean;
selectionText: string;
linkURL: string;
mediaType: string;
srcURL: string;
editFlags: {
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
}
export interface ElectronApi { export interface ElectronApi {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -176,6 +192,8 @@ export interface ElectronApi {
fileExists: (filePath: string) => Promise<boolean>; fileExists: (filePath: string) => Promise<boolean>;
deleteFile: (filePath: string) => Promise<boolean>; deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
command: <T = unknown>(command: ElectronCommand) => Promise<T>; command: <T = unknown>(command: ElectronCommand) => Promise<T>;
query: <T = unknown>(query: ElectronQuery) => Promise<T>; query: <T = unknown>(query: ElectronQuery) => Promise<T>;
} }

View File

@@ -302,7 +302,9 @@ class DebugNetworkSnapshotBuilder {
case 'offer': case 'offer':
case 'answer': case 'answer':
case 'ice_candidate': { case 'ice_candidate': {
const peerId = this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'); const peerId = direction === 'outbound'
? (this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'))
: (this.getPayloadString(payload, 'fromUserId') ?? this.getPayloadString(payload, 'targetPeerId'));
const displayName = this.getPayloadString(payload, 'displayName'); const displayName = this.getPayloadString(payload, 'displayName');
if (!peerId) if (!peerId)
@@ -1295,7 +1297,7 @@ class DebugNetworkSnapshotBuilder {
private getPayloadString(payload: Record<string, unknown> | null, key: string): string | null { private getPayloadString(payload: Record<string, unknown> | null, key: string): string | null {
const value = this.getPayloadField(payload, key); const value = this.getPayloadField(payload, key);
return typeof value === 'string' ? value : null; return this.normalizeStringValue(value);
} }
private getPayloadNumber(payload: Record<string, unknown> | null, key: string): number | null { private getPayloadNumber(payload: Record<string, unknown> | null, key: string): number | null {
@@ -1323,7 +1325,7 @@ class DebugNetworkSnapshotBuilder {
private getStringProperty(record: Record<string, unknown> | null, key: string): string | null { private getStringProperty(record: Record<string, unknown> | null, key: string): string | null {
const value = record?.[key]; const value = record?.[key];
return typeof value === 'string' ? value : null; return this.normalizeStringValue(value);
} }
private getBooleanProperty(record: Record<string, unknown> | null, key: string): boolean | null { private getBooleanProperty(record: Record<string, unknown> | null, key: string): boolean | null {
@@ -1344,4 +1346,16 @@ class DebugNetworkSnapshotBuilder {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
private normalizeStringValue(value: unknown): string | null {
if (typeof value !== 'string')
return null;
const normalized = value.trim();
if (!normalized || normalized === 'undefined' || normalized === 'null')
return null;
return normalized;
}
} }

View File

@@ -0,0 +1,39 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { ServerDirectoryFacade } from '../../server-directory';
import { LinkMetadata } from '../../../shared-kernel';
const URL_PATTERN = /https?:\/\/[^\s<>)"']+/g;
@Injectable({ providedIn: 'root' })
export class LinkMetadataService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
extractUrls(content: string): string[] {
return [...content.matchAll(URL_PATTERN)].map((m) => m[0]);
}
async fetchMetadata(url: string): Promise<LinkMetadata> {
try {
const apiBase = this.serverDirectory.getApiBaseUrl();
const result = await firstValueFrom(
this.http.get<Omit<LinkMetadata, 'url'>>(
`${apiBase}/link-metadata`,
{ params: { url } }
)
);
return { url, ...result };
} catch {
return { url, failed: true };
}
}
async fetchAllMetadata(urls: string[]): Promise<LinkMetadata[]> {
const unique = [...new Set(urls)];
return Promise.all(unique.map((url) => this.fetchMetadata(url)));
}
}

View File

@@ -16,6 +16,7 @@
(downloadRequested)="downloadAttachment($event)" (downloadRequested)="downloadAttachment($event)"
(imageOpened)="openLightbox($event)" (imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)" (imageContextMenuRequested)="openImageContextMenu($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/> />
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"> <div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">

View File

@@ -29,6 +29,7 @@ import {
ChatMessageComposerSubmitEvent, ChatMessageComposerSubmitEvent,
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent, ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent, ChatMessageReactionEvent,
ChatMessageReplyEvent ChatMessageReplyEvent
@@ -191,6 +192,15 @@ export class ChatMessagesComponent {
this.composerBottomPadding.set(height + 20); this.composerBottomPadding.set(height + 20);
} }
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
this.store.dispatch(
MessagesActions.removeLinkEmbed({
messageId: event.messageId,
url: event.url
})
);
}
toggleKlipyGifPicker(): void { toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker(); const nextState = !this.showKlipyGifPicker();

View File

@@ -0,0 +1,47 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
@if (metadata(); as meta) {
@if (!meta.failed && (meta.title || meta.description)) {
<div class="group/embed relative mt-2 max-w-[480px] overflow-hidden rounded-md border border-border/60 bg-secondary/20">
@if (canRemove()) {
<button
type="button"
(click)="removed.emit()"
class="absolute right-1.5 top-1.5 z-10 grid h-5 w-5 place-items-center rounded bg-background/80 text-muted-foreground opacity-0 backdrop-blur-sm transition-opacity hover:text-foreground group-hover/embed:opacity-100"
>
<ng-icon
name="lucideX"
class="h-3 w-3"
/>
</button>
}
<div class="flex">
@if (meta.imageUrl) {
<img
[src]="meta.imageUrl"
[alt]="meta.title || 'Link preview'"
class="hidden h-auto w-28 flex-shrink-0 object-cover sm:block"
loading="lazy"
referrerpolicy="no-referrer"
/>
}
<div class="flex min-w-0 flex-1 flex-col gap-0.5 p-3">
@if (meta.siteName) {
<span class="truncate text-xs text-muted-foreground">{{ meta.siteName }}</span>
}
@if (meta.title) {
<a
[href]="meta.url"
target="_blank"
rel="noopener noreferrer"
class="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
>{{ meta.title }}</a
>
}
@if (meta.description) {
<span class="line-clamp-2 text-xs text-muted-foreground">{{ meta.description }}</span>
}
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,21 @@
import {
Component,
input,
output
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { LinkMetadata } from '../../../../../../shared-kernel';
@Component({
selector: 'app-chat-link-embed',
standalone: true,
imports: [NgIcon],
viewProviders: [provideIcons({ lucideX })],
templateUrl: './chat-link-embed.component.html'
})
export class ChatLinkEmbedComponent {
readonly metadata = input.required<LinkMetadata>();
readonly canRemove = input(false);
readonly removed = output();
}

View File

@@ -89,6 +89,16 @@
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div> <div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
} }
@if (msg.linkMetadata?.length) {
@for (meta of msg.linkMetadata; track meta.url) {
<app-chat-link-embed
[metadata]="meta"
[canRemove]="isOwnMessage() || isAdmin()"
(removed)="removeEmbed(meta.url)"
/>
}
}
@if (attachmentsList.length > 0) { @if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2"> <div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) { @for (att of attachmentsList; track att.id) {

View File

@@ -37,9 +37,11 @@ import {
UserAvatarComponent UserAvatarComponent
} from '../../../../../../shared'; } from '../../../../../../shared';
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component'; import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
import { ChatLinkEmbedComponent } from './chat-link-embed.component';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent, ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent, ChatMessageReactionEvent,
ChatMessageReplyEvent ChatMessageReplyEvent
@@ -59,6 +61,7 @@ const RICH_MARKDOWN_PATTERNS = [
/(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)/m, /(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)/m,
/!\[[^\]]*\]\([^)]+\)/, /!\[[^\]]*\]\([^)]+\)/,
/\[[^\]]+\]\([^)]+\)/, /\[[^\]]+\]\([^)]+\)/,
/https?:\/\/\S+/,
/`[^`\n]+`/, /`[^`\n]+`/,
/\*\*[^*\n]+\*\*|__[^_\n]+__/, /\*\*[^*\n]+\*\*|__[^_\n]+__/,
/\*[^*\n]+\*|_[^_\n]+_/, /\*[^*\n]+\*|_[^_\n]+_/,
@@ -84,6 +87,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
ChatMessageMarkdownComponent, ChatMessageMarkdownComponent,
ChatLinkEmbedComponent,
UserAvatarComponent UserAvatarComponent
], ],
viewProviders: [ viewProviders: [
@@ -126,6 +130,7 @@ export class ChatMessageItemComponent {
readonly downloadRequested = output<Attachment>(); readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>(); readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>(); readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
readonly commonEmojis = COMMON_EMOJIS; readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT; readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
@@ -234,6 +239,13 @@ export class ChatMessageItemComponent {
this.deleteRequested.emit(this.message()); this.deleteRequested.emit(this.message());
} }
removeEmbed(url: string): void {
this.embedRemoved.emit({
messageId: this.message().id,
url
});
}
requestReferenceScroll(messageId: string): void { requestReferenceScroll(messageId: string): void {
this.referenceRequested.emit(messageId); this.referenceRequested.emit(messageId);
} }

View File

@@ -32,4 +32,19 @@
} }
</div> </div>
</ng-template> </ng-template>
<ng-template
[remarkTemplate]="'link'"
let-node
>
<a
[href]="node.url"
[title]="node.title ?? ''"
[remarkNode]="node"
></a>
@if (isYoutubeUrl(node.url)) {
<div class="block">
<app-chat-youtube-embed [url]="node.url" />
</div>
}
</ng-template>
</remark> </remark>

View File

@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { ChatYoutubeEmbedComponent, isYoutubeUrl } from './chat-youtube-embed.component';
const PRISM_LANGUAGE_ALIASES: Record<string, string> = { const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp', cs: 'csharp',
@@ -38,7 +39,8 @@ const REMARK_PROCESSOR = unified()
CommonModule, CommonModule,
RemarkModule, RemarkModule,
MermaidComponent, MermaidComponent,
ChatImageProxyFallbackDirective ChatImageProxyFallbackDirective,
ChatYoutubeEmbedComponent
], ],
templateUrl: './chat-message-markdown.component.html' templateUrl: './chat-message-markdown.component.html'
}) })
@@ -57,6 +59,10 @@ export class ChatMessageMarkdownComponent {
return KLIPY_MEDIA_URL_PATTERN.test(url); return KLIPY_MEDIA_URL_PATTERN.test(url);
} }
isYoutubeUrl(url?: string): boolean {
return isYoutubeUrl(url);
}
isMermaidCodeBlock(lang?: string): boolean { isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid'; return this.normalizeCodeLanguage(lang) === 'mermaid';
} }

View File

@@ -0,0 +1,48 @@
import { Component, computed, input } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
@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>
}
`
})
export class ChatYoutubeEmbedComponent {
readonly url = input.required<string>();
readonly videoId = computed(() => {
const match = this.url().match(YOUTUBE_URL_PATTERN);
return match?.[1] ?? null;
});
readonly embedUrl = computed(() => {
const id = this.videoId();
if (!id)
return '';
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`
);
});
constructor(private readonly sanitizer: DomSanitizer) {}
}
export function isYoutubeUrl(url?: string): boolean {
return !!url && YOUTUBE_URL_PATTERN.test(url);
}

View File

@@ -62,6 +62,7 @@
(downloadRequested)="handleDownloadRequested($event)" (downloadRequested)="handleDownloadRequested($event)"
(imageOpened)="handleImageOpened($event)" (imageOpened)="handleImageOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)" (imageContextMenuRequested)="handleImageContextMenuRequested($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/> />
} }
} }

View File

@@ -18,6 +18,7 @@ import { Message } from '../../../../../../shared-kernel';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent, ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent, ChatMessageReactionEvent,
ChatMessageReplyEvent ChatMessageReplyEvent
@@ -69,6 +70,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
readonly downloadRequested = output<Attachment>(); readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>(); readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>(); readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
private readonly PAGE_SIZE = 50; private readonly PAGE_SIZE = 50;
@@ -299,6 +301,10 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.imageContextMenuRequested.emit(event); this.imageContextMenuRequested.emit(event);
} }
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
this.embedRemoved.emit(event);
}
private resetScrollingState(): void { private resetScrollingState(): void {
this.initialScrollPending = true; this.initialScrollPending = true;
this.stopInitialScrollWatch(); this.stopInitialScrollWatch();

View File

@@ -29,3 +29,8 @@ export interface ChatMessageImageContextMenuEvent {
export type ChatMessageReplyEvent = Message; export type ChatMessageReplyEvent = Message;
export type ChatMessageDeleteEvent = Message; export type ChatMessageDeleteEvent = Message;
export interface ChatMessageEmbedRemoveEvent {
messageId: string;
url: string;
}

View File

@@ -125,8 +125,13 @@ export class RoomsSidePanelComponent {
}); });
onlineRoomUsers = computed(() => { onlineRoomUsers = computed(() => {
const memberIdentifiers = this.roomMemberIdentifiers(); const memberIdentifiers = this.roomMemberIdentifiers();
const roomId = this.currentRoom()?.id;
return this.onlineUsers().filter((user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user)); return this.onlineUsers().filter((user) =>
!this.isCurrentUserIdentity(user)
&& this.matchesIdentifiers(memberIdentifiers, user)
&& this.isUserPresentInRoom(user, roomId)
);
}); });
offlineRoomMembers = computed(() => { offlineRoomMembers = computed(() => {
const onlineIdentifiers = new Set<string>(); const onlineIdentifiers = new Set<string>();
@@ -200,6 +205,14 @@ export class RoomsSidePanelComponent {
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId))); return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
} }
private isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean {
if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) {
return true;
}
return entity.presenceServerIds.includes(roomId);
}
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean { private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
const current = this.currentUser(); const current = this.currentUser();

View File

@@ -13,21 +13,19 @@
</button> </button>
<!-- Saved servers icons --> <!-- Saved servers icons -->
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto"> <div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5">
@for (room of visibleSavedRooms(); track room.id) { @for (room of visibleSavedRooms(); track room.id) {
<div class="relative flex w-full justify-center"> <div class="group/server relative flex w-full justify-center">
@if (isSelectedRoom(room)) {
<span <span
aria-hidden="true" aria-hidden="true"
class="pointer-events-none absolute left-0 top-1/2 h-10 w-1 -translate-y-1/2 rounded-l-full bg-primary" class="pointer-events-none absolute left-0 top-1/2 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-[height,opacity] duration-100"
[ngClass]="isSelectedRoom(room) ? 'h-5 opacity-100' : 'h-0 opacity-0 group-hover/server:h-2.5 group-hover/server:opacity-100'"
></span> ></span>
}
<button <button
type="button" type="button"
class="relative z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md border border-transparent bg-card transition-colors hover:border-border hover:bg-card" class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
[class.border-primary/30]="isSelectedRoom(room)" [ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[class.bg-primary/10]="isSelectedRoom(room)"
[title]="room.name" [title]="room.name"
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null" [attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
(click)="joinSavedRoom(room)" (click)="joinSavedRoom(room)"

View File

@@ -0,0 +1,82 @@
@if (params()) {
<app-context-menu
[x]="params()!.posX"
[y]="params()!.posY"
[width]="'w-44'"
(closed)="close()"
>
@if (params()!.isEditable) {
<button
type="button"
class="context-menu-item"
[disabled]="!params()!.editFlags.canCut"
[class.opacity-40]="!params()!.editFlags.canCut"
(click)="execCommand('cut')"
>
Cut
</button>
<button
type="button"
class="context-menu-item"
[disabled]="!params()!.editFlags.canCopy"
[class.opacity-40]="!params()!.editFlags.canCopy"
(click)="execCommand('copy')"
>
Copy
</button>
<button
type="button"
class="context-menu-item"
[disabled]="!params()!.editFlags.canPaste"
[class.opacity-40]="!params()!.editFlags.canPaste"
(click)="execCommand('paste')"
>
Paste
</button>
<div class="context-menu-divider"></div>
<button
type="button"
class="context-menu-item"
[disabled]="!params()!.editFlags.canSelectAll"
[class.opacity-40]="!params()!.editFlags.canSelectAll"
(click)="execCommand('selectAll')"
>
Select All
</button>
} @else if (params()!.selectionText) {
<button
type="button"
class="context-menu-item"
(click)="execCommand('copy')"
>
Copy
</button>
}
@if (params()!.linkURL) {
@if (params()!.isEditable || params()!.selectionText) {
<div class="context-menu-divider"></div>
}
<button
type="button"
class="context-menu-item"
(click)="copyLink()"
>
Copy Link
</button>
}
@if (params()!.mediaType === 'image' && params()!.srcURL) {
@if (params()!.isEditable || params()!.selectionText || params()!.linkURL) {
<div class="context-menu-divider"></div>
}
<button
type="button"
class="context-menu-item"
(click)="copyImage()"
>
Copy Image
</button>
}
</app-context-menu>
}

View File

@@ -0,0 +1,75 @@
import {
Component,
OnInit,
OnDestroy,
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';
@Component({
selector: 'app-native-context-menu',
standalone: true,
imports: [ContextMenuComponent],
templateUrl: './native-context-menu.component.html'
})
export class NativeContextMenuComponent implements OnInit, OnDestroy {
params = signal<ContextMenuParams | null>(null);
private readonly electronBridge = inject(ElectronBridgeService);
private cleanup: (() => void) | null = null;
ngOnInit(): void {
const api = this.electronBridge.getApi();
if (!api?.onContextMenu) {
return;
}
this.cleanup = api.onContextMenu((incoming) => {
const hasContent = incoming.isEditable
|| !!incoming.selectionText
|| !!incoming.linkURL
|| (incoming.mediaType === 'image' && !!incoming.srcURL);
this.params.set(hasContent ? incoming : null);
});
}
ngOnDestroy(): void {
this.cleanup?.();
this.cleanup = null;
}
close(): void {
this.params.set(null);
}
execCommand(command: string): void {
document.execCommand(command);
this.close();
}
copyLink(): void {
const url = this.params()?.linkURL;
if (url) {
navigator.clipboard.writeText(url).catch(() => {});
}
this.close();
}
copyImage(): void {
const srcURL = this.params()?.srcURL;
const api = this.electronBridge.getApi();
if (srcURL && api?.copyImageToClipboard) {
api.copyImageToClipboard(srcURL).catch(() => {});
}
this.close();
}
}

View File

@@ -144,13 +144,25 @@ sequenceDiagram
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything. When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything.
### Server-side connection hygiene
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). The server's `handleIdentify` now closes any existing connection that shares the same `oderId` but a different `connectionId`. This guarantees `findUserByOderId` always routes offers and presence events to the freshest socket, eliminating a class of bugs where signaling messages landed on a dead tab's socket and were silently lost.
Join and leave broadcasts are also identity-aware: `handleJoinServer` only broadcasts `user_joined` when the identity is genuinely new to that server (not just a second WebSocket connection for the same user), and `handleLeaveServer` / dead-connection cleanup only broadcast `user_left` when no other open connection for that identity remains in the server. The `user_left` payload includes `serverIds` listing the rooms the identity still belongs to, so the client can subtract correctly without over-removing.
### Multi-room presence
`server_users`, `user_joined`, and `user_left` are room-scoped presence messages, but the renderer must treat them as updates into a global multi-room presence view. The users store tracks `presenceServerIds` per user instead of clearing the whole slice when a new `server_users` snapshot arrives, so startup/search background rooms keep their server-rail voice badges and active voice peers do not disappear when the user views a different server.
Peer routing also has to stay scoped to the signaling server that reported the membership. A `user_left` from one signaling cluster must only subtract that cluster's shared servers; otherwise a leave on `signal.toju.app` can incorrectly tear down a peer that is still shared through `signal-sweden.toju.app` or a local signaling server. Route metadata is therefore kept across peer recreation and only cleared once the renderer no longer shares any servers with that peer.
## Peer connection lifecycle ## Peer connection lifecycle
Peers connect to each other directly with `RTCPeerConnection`. The "initiator" (whoever was already in the room) creates the data channel and audio/video transceivers, then sends an offer. The other side creates an answer. Peers connect to each other directly with `RTCPeerConnection`. The initiator is chosen deterministically from the identified logical peer IDs so only one side creates the offer and primary data channel for a given pair. The other side creates an answer. If identity or negotiation is still settling, the retry timer defers instead of comparing against the ephemeral local transport ID or reusing a half-open peer forever.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant A as Peer A (initiator) participant A as Peer A (elected initiator)
participant Sig as Signaling Server participant Sig as Signaling Server
participant B as Peer B participant B as Peer B
@@ -180,6 +192,16 @@ sequenceDiagram
Both peers might send offers at the same time ("glare"). The negotiation module implements the "polite peer" pattern: one side is designated polite (the non-initiator) and will roll back its local offer if it detects a collision, then accept the remote offer instead. The impolite side ignores the incoming offer. Both peers might send offers at the same time ("glare"). The negotiation module implements the "polite peer" pattern: one side is designated polite (the non-initiator) and will roll back its local offer if it detects a collision, then accept the remote offer instead. The impolite side ignores the incoming offer.
Existing members also schedule a short `user_joined` fallback offer, and the `server_users` path now re-arms the same retry when an initial attempt stalls. The joiner still tries first via its `server_users` snapshot, but the fallback heals late-join races or half-open peers where that initial offer never arrives or never finishes. The retry uses the same deterministic initiator election as the main `server_users` path so the pair cannot regress into dual initiators.
### Non-initiator takeover
If the elected initiator's offer never arrives (stale socket, network issue, page still loading), the non-initiator does not wait forever. It tracks the start of each waiting period in `nonInitiatorWaitStart`. For the first `NON_INITIATOR_GIVE_UP_MS` (5 s) it reschedules and logs. Once that window expires it takes over: removes any stale peer, creates a fresh `RTCPeerConnection` as initiator, and sends its own offer. This ensures every peer pair eventually establishes a connection regardless of which side was originally elected.
### Stale peer replacement
Offers or ICE candidates can arrive while the existing `RTCPeerConnection` for that peer is in `failed` or `closed` state (the browser's `connectionstatechange` event hasn't fired yet to clean it up). `replaceUnusablePeer()` in `negotiation.ts` detects this, closes the dead connection, removes it from the active map, and lets the caller proceed with a fresh peer. The `connectionstatechange` handler in `create-peer-connection.ts` also guards against stale events: if the connection object no longer matches the current map entry for that peer, the event is ignored so it cannot accidentally remove a replacement peer.
### Disconnect recovery ### Disconnect recovery
```mermaid ```mermaid
@@ -196,7 +218,7 @@ stateDiagram-v2
Closed --> [*] Closed --> [*]
``` ```
When a peer connection enters `disconnected`, a 10-second grace period starts. If it recovers on its own (network blip), nothing happens. If it reaches `failed`, the connection is torn down and a reconnect loop starts: a fresh `RTCPeerConnection` is created and a new offer is sent every 5 seconds, up to 12 attempts. When a peer connection enters `disconnected`, a 10-second grace period starts. If it recovers on its own (network blip), nothing happens. If it reaches `failed`, the connection is torn down and a reconnect loop starts. A fresh `RTCPeerConnection` is created every 5 seconds, up to 12 attempts; only the deterministically elected initiator sends a reconnect offer, while the other side waits for that offer.
## Data channel ## Data channel

View File

@@ -31,6 +31,35 @@ export function createPeerConnection(
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS }); const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
let dataChannel: RTCDataChannel | null = null; let dataChannel: RTCDataChannel | null = null;
let peerData: PeerData | null = null;
const adoptDataChannel = (channel: RTCDataChannel): void => {
const primaryChannel = dataChannel;
const shouldAdoptAsPrimary = !primaryChannel || primaryChannel.readyState === 'closed';
if (shouldAdoptAsPrimary) {
dataChannel = channel;
if (peerData) {
peerData.dataChannel = channel;
}
const existing = state.activePeerConnections.get(remotePeerId);
if (existing) {
existing.dataChannel = channel;
}
} else if (primaryChannel !== channel) {
logger.info('Received secondary data channel while primary channel is still active', {
channelLabel: channel.label,
primaryChannelLabel: primaryChannel.label,
primaryReadyState: primaryChannel.readyState,
remotePeerId
});
}
handlers.setupDataChannel(channel, remotePeerId);
};
connection.onicecandidate = (event) => { connection.onicecandidate = (event) => {
if (event.candidate) { if (event.candidate) {
@@ -53,6 +82,19 @@ export function createPeerConnection(
state: connection.connectionState state: connection.connectionState
}); });
// Ignore events from a connection that was already replaced in the Map
// (e.g. handleOffer recreated the peer while this handler was still queued).
const currentPeer = state.activePeerConnections.get(remotePeerId);
if (currentPeer && currentPeer.connection !== connection) {
logger.info('Ignoring stale connectionstatechange', {
remotePeerId,
state: connection.connectionState
});
return;
}
recordDebugNetworkConnectionState(remotePeerId, connection.connectionState); recordDebugNetworkConnectionState(remotePeerId, connection.connectionState);
switch (connection.connectionState) { switch (connection.connectionState) {
@@ -103,27 +145,20 @@ export function createPeerConnection(
handlers.handleRemoteTrack(event, remotePeerId); handlers.handleRemoteTrack(event, remotePeerId);
}; };
connection.ondatachannel = (event) => {
logger.info('Received data channel', { remotePeerId });
adoptDataChannel(event.channel);
};
if (isInitiator) { if (isInitiator) {
dataChannel = connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true }); dataChannel = connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
handlers.setupDataChannel(dataChannel, remotePeerId); handlers.setupDataChannel(dataChannel, remotePeerId);
} else {
connection.ondatachannel = (event) => {
logger.info('Received data channel', { remotePeerId });
dataChannel = event.channel;
const existing = state.activePeerConnections.get(remotePeerId);
if (existing) {
existing.dataChannel = dataChannel;
} }
handlers.setupDataChannel(dataChannel, remotePeerId); peerData = {
};
}
const peerData: PeerData = {
connection, connection,
dataChannel, dataChannel,
createdAt: Date.now(),
isInitiator, isInitiator,
pendingIceCandidates: [], pendingIceCandidates: [],
audioSender: undefined, audioSender: undefined,

View File

@@ -60,6 +60,41 @@ export async function doCreateAndSendOffer(
} }
} }
/**
* Replace a peer whose underlying connection is `failed` or `closed`.
* Returns the existing peer if still usable, or `undefined` after cleanup.
*/
function replaceUnusablePeer(
context: PeerConnectionManagerContext,
peerId: string,
reason: string
): void {
const { logger, state } = context;
const peerData = state.activePeerConnections.get(peerId);
if (!peerData)
return;
const cs = peerData.connection.connectionState;
if (cs !== 'failed' && cs !== 'closed')
return;
logger.info('Replacing unusable peer', {
connectionState: cs,
peerId,
reason,
signalingState: peerData.connection.signalingState
});
try {
peerData.connection.close();
} catch { /* already closing */ }
state.activePeerConnections.delete(peerId);
state.peerNegotiationQueue.delete(peerId);
}
export async function doHandleOffer( export async function doHandleOffer(
context: PeerConnectionManagerContext, context: PeerConnectionManagerContext,
fromUserId: string, fromUserId: string,
@@ -70,6 +105,8 @@ export async function doHandleOffer(
logger.info('Handling offer', { fromUserId }); logger.info('Handling offer', { fromUserId });
replaceUnusablePeer(context, fromUserId, 'incoming offer');
let peerData = state.activePeerConnections.get(fromUserId); let peerData = state.activePeerConnections.get(fromUserId);
if (!peerData) { if (!peerData) {
@@ -82,16 +119,15 @@ export async function doHandleOffer(
signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer'; signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
if (hasCollision) { if (hasCollision) {
const localId = const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
callbacks.getIdentifyCredentials()?.oderId || callbacks.getLocalPeerId(); const isPolite = !localOderId || localOderId > fromUserId;
const isPolite = localId > fromUserId;
if (!isPolite) { if (!isPolite) {
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId }); logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
return; return;
} }
logger.info('Rolling back local offer (polite side)', { fromUserId, localId }); logger.info('Rolling back local offer (polite side)', { fromUserId, localOderId });
await peerData.connection.setLocalDescription({ await peerData.connection.setLocalDescription({
type: 'rollback' type: 'rollback'
@@ -211,6 +247,8 @@ export async function doHandleIceCandidate(
): Promise<void> { ): Promise<void> {
const { logger, state } = context; const { logger, state } = context;
replaceUnusablePeer(context, fromUserId, 'early ICE');
let peerData = state.activePeerConnections.get(fromUserId); let peerData = state.activePeerConnections.get(fromUserId);
if (!peerData) { if (!peerData) {

View File

@@ -484,15 +484,23 @@ function summarizePeerMessage(payload: PeerMessage, base?: Record<string, unknow
} }
if (voiceState) { if (voiceState) {
summary['voiceState'] = { const voiceStateSummary: Record<string, unknown> = {
isConnected: voiceState['isConnected'] === true, isConnected: voiceState['isConnected'] === true,
isMuted: voiceState['isMuted'] === true, isMuted: voiceState['isMuted'] === true,
isDeafened: voiceState['isDeafened'] === true, isDeafened: voiceState['isDeafened'] === true,
isSpeaking: voiceState['isSpeaking'] === true, isSpeaking: voiceState['isSpeaking'] === true
roomId: typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined,
serverId: typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined,
volume: typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined
}; };
if (typeof voiceState['roomId'] === 'string')
voiceStateSummary['roomId'] = voiceState['roomId'];
if (typeof voiceState['serverId'] === 'string')
voiceStateSummary['serverId'] = voiceState['serverId'];
if (typeof voiceState['volume'] === 'number')
voiceStateSummary['volume'] = voiceState['volume'];
summary['voiceState'] = voiceStateSummary;
} }
return summary; return summary;

View File

@@ -200,23 +200,44 @@ export function schedulePeerReconnect(
return; return;
} }
attemptPeerReconnect(state, peerId, handlers); attemptPeerReconnect(context, peerId, handlers);
}, PEER_RECONNECT_INTERVAL_MS); }, PEER_RECONNECT_INTERVAL_MS);
state.peerReconnectTimers.set(peerId, timer); state.peerReconnectTimers.set(peerId, timer);
} }
export function attemptPeerReconnect( export function attemptPeerReconnect(
state: PeerConnectionManagerState, context: PeerConnectionManagerContext,
peerId: string, peerId: string,
handlers: RecoveryHandlers handlers: RecoveryHandlers
): void { ): void {
const { callbacks, logger, state } = context;
if (state.activePeerConnections.has(peerId)) { if (state.activePeerConnections.has(peerId)) {
handlers.removePeer(peerId, { preserveReconnectState: true }); handlers.removePeer(peerId, { preserveReconnectState: true });
} }
handlers.createPeerConnection(peerId, true); const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
if (!localOderId) {
logger.info('Skipping reconnect offer until logical identity is ready', { peerId });
handlers.createPeerConnection(peerId, false);
return;
}
const shouldInitiate = peerId !== localOderId && localOderId < peerId;
handlers.createPeerConnection(peerId, shouldInitiate);
if (shouldInitiate) {
void handlers.createAndSendOffer(peerId); void handlers.createAndSendOffer(peerId);
return;
}
logger.info('Waiting for remote reconnect offer based on deterministic initiator selection', {
localOderId,
peerId
});
} }
export function requestVoiceStateFromPeer( export function requestVoiceStateFromPeer(

View File

@@ -176,6 +176,7 @@ export class WebRTCService implements OnDestroy {
}); });
this.signalingMessageHandler = new IncomingSignalingMessageHandler({ this.signalingMessageHandler = new IncomingSignalingMessageHandler({
getLocalOderId: () => this.signalingTransportHandler.getIdentifyCredentials()?.oderId ?? null,
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId), getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
peerManager: this.peerManager, peerManager: this.peerManager,
setServerTime: (serverTime) => this.timeSync.setFromServerTime(serverTime), setServerTime: (serverTime) => this.timeSync.setFromServerTime(serverTime),
@@ -229,7 +230,6 @@ export class WebRTCService implements OnDestroy {
this.peerManager.peerDisconnected$.subscribe((peerId) => { this.peerManager.peerDisconnected$.subscribe((peerId) => {
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId); this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
this.signalingCoordinator.deletePeerTracking(peerId);
}); });
// Media manager → voice connected signal // Media manager → voice connected signal

View File

@@ -8,6 +8,8 @@ export interface PeerData {
connection: RTCPeerConnection; connection: RTCPeerConnection;
/** The negotiated data channel, or `null` before the channel is established. */ /** The negotiated data channel, or `null` before the channel is established. */
dataChannel: RTCDataChannel | null; dataChannel: RTCDataChannel | null;
/** Timestamp (ms since epoch) when this peer attempt was created. */
createdAt: number;
/** `true` when this side created the offer (and data channel). */ /** `true` when this side created the offer (and data channel). */
isInitiator: boolean; isInitiator: boolean;
/** ICE candidates received before the remote description was set. */ /** ICE candidates received before the remote description was set. */

View File

@@ -23,9 +23,10 @@ export class ServerSignalingCoordinator<TMessage> {
private readonly memberServerIdsBySignalUrl = new Map<string, Set<string>>(); private readonly memberServerIdsBySignalUrl = new Map<string, Set<string>>();
private readonly serverSignalingUrlMap = new Map<string, string>(); private readonly serverSignalingUrlMap = new Map<string, string>();
private readonly peerSignalingUrlMap = new Map<string, string>(); private readonly peerSignalingUrlMap = new Map<string, string>();
private readonly peerKnownSignalUrls = new Map<string, Set<string>>();
private readonly signalingManagers = new Map<string, SignalingManager>(); private readonly signalingManagers = new Map<string, SignalingManager>();
private readonly signalingSubscriptions = new Map<string, Subscription[]>(); private readonly signalingSubscriptions = new Map<string, Subscription[]>();
private readonly peerServerMap = new Map<string, Set<string>>(); private readonly peerServerMap = new Map<string, Map<string, Set<string>>>();
constructor( constructor(
private readonly callbacks: ServerSignalingCoordinatorCallbacks<TMessage> private readonly callbacks: ServerSignalingCoordinatorCallbacks<TMessage>
@@ -126,15 +127,28 @@ export class ServerSignalingCoordinator<TMessage> {
} }
setPeerSignalUrl(peerId: string, signalUrl: string): void { setPeerSignalUrl(peerId: string, signalUrl: string): void {
const knownSignalUrls = this.peerKnownSignalUrls.get(peerId) ?? new Set<string>();
knownSignalUrls.add(signalUrl);
this.peerKnownSignalUrls.set(peerId, knownSignalUrls);
this.peerSignalingUrlMap.set(peerId, signalUrl); this.peerSignalingUrlMap.set(peerId, signalUrl);
} }
getPeerSignalUrl(peerId: string): string | undefined { getPeerSignalUrl(peerId: string): string | undefined {
return this.peerSignalingUrlMap.get(peerId); const preferredSignalUrl = this.peerSignalingUrlMap.get(peerId);
if (preferredSignalUrl) {
return preferredSignalUrl;
}
const knownSignalUrls = this.peerKnownSignalUrls.get(peerId);
return knownSignalUrls?.values().next().value;
} }
deletePeerSignalUrl(peerId: string): void { deletePeerSignalUrl(peerId: string): void {
this.peerSignalingUrlMap.delete(peerId); this.peerSignalingUrlMap.delete(peerId);
this.peerKnownSignalUrls.delete(peerId);
} }
addJoinedServer(signalUrl: string, serverId: string): void { addJoinedServer(signalUrl: string, serverId: string): void {
@@ -197,64 +211,86 @@ export class ServerSignalingCoordinator<TMessage> {
return joinedServerIds; return joinedServerIds;
} }
trackPeerInServer(peerId: string, serverId: string): void { trackPeerInServer(peerId: string, serverId: string, signalUrl: string): void {
if (!peerId || !serverId) if (!peerId || !serverId || !signalUrl)
return; return;
const trackedServers = this.peerServerMap.get(peerId) ?? new Set<string>(); const trackedSignalUrls = this.peerServerMap.get(peerId) ?? new Map<string, Set<string>>();
const trackedServers = trackedSignalUrls.get(signalUrl) ?? new Set<string>();
trackedServers.add(serverId); trackedServers.add(serverId);
this.peerServerMap.set(peerId, trackedServers); trackedSignalUrls.set(signalUrl, trackedServers);
this.peerServerMap.set(peerId, trackedSignalUrls);
this.setPeerSignalUrl(peerId, signalUrl);
} }
hasTrackedPeerServers(peerId: string): boolean { hasTrackedPeerServers(peerId: string): boolean {
return this.peerServerMap.has(peerId); return this.getTrackedServerIds(peerId).size > 0;
} }
replacePeerSharedServers(peerId: string, serverIds: string[]): boolean { replacePeerSharedServers(peerId: string, signalUrl: string, serverIds: string[]): boolean {
const sharedServerIds = serverIds.filter((serverId) => this.hasJoinedServer(serverId)); const sharedServerIds = serverIds.filter((serverId) => this.hasJoinedServer(serverId));
if (sharedServerIds.length === 0) { if (sharedServerIds.length === 0) {
this.peerServerMap.delete(peerId); this.removePeerSignalScope(peerId, signalUrl);
return false; return this.hasTrackedPeerServers(peerId);
} }
this.peerServerMap.set(peerId, new Set(sharedServerIds)); const trackedSignalUrls = this.peerServerMap.get(peerId) ?? new Map<string, Set<string>>();
trackedSignalUrls.set(signalUrl, new Set(sharedServerIds));
this.peerServerMap.set(peerId, trackedSignalUrls);
this.setPeerSignalUrl(peerId, signalUrl);
return true; return true;
} }
untrackPeerFromServer(peerId: string, serverId: string): boolean { untrackPeerFromServer(peerId: string, signalUrl: string, serverId: string): boolean {
const trackedServers = this.peerServerMap.get(peerId); const trackedSignalUrls = this.peerServerMap.get(peerId);
if (!trackedSignalUrls)
return false;
const trackedServers = trackedSignalUrls.get(signalUrl);
if (!trackedServers) if (!trackedServers)
return false; return this.hasTrackedPeerServers(peerId);
trackedServers.delete(serverId); trackedServers.delete(serverId);
if (trackedServers.size === 0) { if (trackedServers.size === 0) {
trackedSignalUrls.delete(signalUrl);
this.untrackPeerSignalUrl(peerId, signalUrl);
} else {
trackedSignalUrls.set(signalUrl, trackedServers);
}
if (trackedSignalUrls.size === 0) {
this.peerServerMap.delete(peerId); this.peerServerMap.delete(peerId);
return false; return false;
} }
this.peerServerMap.set(peerId, trackedServers); this.peerServerMap.set(peerId, trackedSignalUrls);
return true; return true;
} }
deletePeerTracking(peerId: string): void { deletePeerTracking(peerId: string): void {
this.peerServerMap.delete(peerId); this.peerServerMap.delete(peerId);
this.peerSignalingUrlMap.delete(peerId); this.peerSignalingUrlMap.delete(peerId);
this.peerKnownSignalUrls.delete(peerId);
} }
clearPeerTracking(): void { clearPeerTracking(): void {
this.peerServerMap.clear(); this.peerServerMap.clear();
this.peerSignalingUrlMap.clear(); this.peerSignalingUrlMap.clear();
this.peerKnownSignalUrls.clear();
} }
getPeersOutsideServer(serverId: string): string[] { getPeersOutsideServer(serverId: string): string[] {
const peersToClose: string[] = []; const peersToClose: string[] = [];
this.peerServerMap.forEach((peerServerIds, peerId) => { this.peerServerMap.forEach((_peerServerIdsBySignalUrl, peerId) => {
if (!peerServerIds.has(serverId)) { if (!this.getTrackedServerIds(peerId).has(serverId)) {
peersToClose.push(peerId); peersToClose.push(peerId);
} }
}); });
@@ -292,4 +328,64 @@ export class ServerSignalingCoordinator<TMessage> {
this.memberServerIdsBySignalUrl.set(signalUrl, createdSet); this.memberServerIdsBySignalUrl.set(signalUrl, createdSet);
return createdSet; return createdSet;
} }
private getTrackedServerIds(peerId: string): Set<string> {
const trackedServerIds = new Set<string>();
const trackedSignalUrls = this.peerServerMap.get(peerId);
if (!trackedSignalUrls) {
return trackedServerIds;
}
trackedSignalUrls.forEach((serverIds) => {
serverIds.forEach((serverId) => trackedServerIds.add(serverId));
});
return trackedServerIds;
}
private removePeerSignalScope(peerId: string, signalUrl: string): void {
const trackedSignalUrls = this.peerServerMap.get(peerId);
if (!trackedSignalUrls) {
this.untrackPeerSignalUrl(peerId, signalUrl);
return;
}
trackedSignalUrls.delete(signalUrl);
if (trackedSignalUrls.size === 0) {
this.peerServerMap.delete(peerId);
} else {
this.peerServerMap.set(peerId, trackedSignalUrls);
}
this.untrackPeerSignalUrl(peerId, signalUrl);
}
private untrackPeerSignalUrl(peerId: string, signalUrl: string): void {
const knownSignalUrls = this.peerKnownSignalUrls.get(peerId);
if (!knownSignalUrls) {
if (this.peerSignalingUrlMap.get(peerId) === signalUrl) {
this.peerSignalingUrlMap.delete(peerId);
}
return;
}
knownSignalUrls.delete(signalUrl);
if (knownSignalUrls.size === 0) {
this.peerKnownSignalUrls.delete(peerId);
this.peerSignalingUrlMap.delete(peerId);
return;
}
this.peerKnownSignalUrls.set(peerId, knownSignalUrls);
if (this.peerSignalingUrlMap.get(peerId) === signalUrl) {
this.peerSignalingUrlMap.set(peerId, knownSignalUrls.values().next().value as string);
}
}
} }

View File

@@ -39,11 +39,26 @@ interface IncomingSignalingMessageHandlerDependencies {
peerManager: PeerConnectionManager; peerManager: PeerConnectionManager;
signalingCoordinator: ServerSignalingCoordinator<IncomingSignalingMessage>; signalingCoordinator: ServerSignalingCoordinator<IncomingSignalingMessage>;
logger: WebRTCLogger; logger: WebRTCLogger;
getLocalOderId(): string | null;
getEffectiveServerId(): string | null; getEffectiveServerId(): string | null;
setServerTime(serverTime: number): void; setServerTime(serverTime: number): void;
} }
const USER_JOINED_FALLBACK_OFFER_DELAY_MS = 1_000;
const PEER_NEGOTIATION_GRACE_MS = 3_000;
// Once a local offer has been sent, the peer is actively in negotiation - wait much
// longer before treating it as stale, so a slow answer path doesn't cause an
// unnecessary teardown/re-offer cycle.
const PEER_NEGOTIATION_OFFER_SENT_GRACE_MS = 20_000;
// How long the non-initiator waits for the elected initiator's offer before
// giving up and creating the connection itself.
const NON_INITIATOR_GIVE_UP_MS = 5_000;
export class IncomingSignalingMessageHandler { export class IncomingSignalingMessageHandler {
private readonly userJoinedFallbackTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Tracks when we first started waiting for a remote-initiated offer from each peer. */
private readonly nonInitiatorWaitStart = new Map<string, number>();
constructor( constructor(
private readonly dependencies: IncomingSignalingMessageHandlerDependencies private readonly dependencies: IncomingSignalingMessageHandlerDependencies
) {} ) {}
@@ -105,6 +120,7 @@ export class IncomingSignalingMessageHandler {
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
const users = Array.isArray(message.users) ? message.users : []; const users = Array.isArray(message.users) ? message.users : [];
const localOderId = this.dependencies.getLocalOderId();
this.dependencies.logger.info('Server users', { this.dependencies.logger.info('Server users', {
count: users.length, count: users.length,
@@ -120,15 +136,22 @@ export class IncomingSignalingMessageHandler {
if (!user.oderId) if (!user.oderId)
continue; continue;
if (localOderId && user.oderId === localOderId)
continue;
this.clearUserJoinedFallbackOffer(user.oderId);
this.dependencies.signalingCoordinator.setPeerSignalUrl(user.oderId, signalUrl); this.dependencies.signalingCoordinator.setPeerSignalUrl(user.oderId, signalUrl);
if (message.serverId) { if (message.serverId) {
this.dependencies.signalingCoordinator.trackPeerInServer(user.oderId, message.serverId); this.dependencies.signalingCoordinator.trackPeerInServer(user.oderId, message.serverId, signalUrl);
} }
const existing = this.dependencies.peerManager.activePeerConnections.get(user.oderId); const existing = this.dependencies.peerManager.activePeerConnections.get(user.oderId);
if (this.canReusePeerConnection(existing)) { if (this.hasActivePeerConnection(existing)) {
// Peer is already up - move on (timer already cleared above).
this.nonInitiatorWaitStart.delete(user.oderId);
this.dependencies.logger.info('Reusing active peer connection', { this.dependencies.logger.info('Reusing active peer connection', {
connectionState: existing?.connection.connectionState ?? 'unknown', connectionState: existing?.connection.connectionState ?? 'unknown',
dataChannelState: existing?.dataChannel?.readyState ?? 'missing', dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
@@ -140,6 +163,56 @@ export class IncomingSignalingMessageHandler {
continue; continue;
} }
this.scheduleUserJoinedFallbackOffer(user.oderId, signalUrl, message.serverId);
if (this.isPeerConnectionNegotiating(existing)) {
this.dependencies.logger.info('Awaiting existing peer negotiation from server_users snapshot', {
ageMs: existing ? Date.now() - existing.createdAt : undefined,
connectionState: existing?.connection.connectionState ?? 'unknown',
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
oderId: user.oderId,
serverId: message.serverId,
signalUrl
});
continue;
}
if (!localOderId) {
this.dependencies.logger.info('Deferring server_users peer initiation until logical identity is ready', {
oderId: user.oderId,
serverId: message.serverId,
signalUrl
});
continue;
}
const shouldInitiate = this.shouldInitiatePeer(user.oderId, localOderId);
if (!shouldInitiate) {
if (existing) {
this.dependencies.logger.info('Removing stale peer while waiting for remote offer', {
connectionState: existing.connection.connectionState,
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
oderId: user.oderId,
serverId: message.serverId,
signalUrl
});
this.dependencies.peerManager.removePeer(user.oderId);
}
this.dependencies.logger.info('Waiting for remote offer based on deterministic initiator selection', {
localOderId,
oderId: user.oderId,
serverId: message.serverId,
signalUrl
});
continue;
}
if (existing) { if (existing) {
this.dependencies.logger.info('Removing failed peer before recreate', { this.dependencies.logger.info('Removing failed peer before recreate', {
connectionState: existing.connection.connectionState, connectionState: existing.connection.connectionState,
@@ -164,6 +237,10 @@ export class IncomingSignalingMessageHandler {
} }
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
if (message.oderId && message.oderId === this.dependencies.getLocalOderId()) {
return;
}
this.dependencies.logger.info('User joined', { this.dependencies.logger.info('User joined', {
displayName: message.displayName, displayName: message.displayName,
oderId: message.oderId, oderId: message.oderId,
@@ -179,11 +256,27 @@ export class IncomingSignalingMessageHandler {
} }
if (message.oderId && message.serverId) { if (message.oderId && message.serverId) {
this.dependencies.signalingCoordinator.trackPeerInServer(message.oderId, message.serverId); this.dependencies.signalingCoordinator.trackPeerInServer(message.oderId, message.serverId, signalUrl);
}
if (message.oderId) {
const existing = this.dependencies.peerManager.activePeerConnections.get(message.oderId);
if (this.hasActivePeerConnection(existing)) {
// Already connected - cancel any stale timer and move on.
this.clearUserJoinedFallbackOffer(message.oderId);
this.nonInitiatorWaitStart.delete(message.oderId);
} else {
this.scheduleUserJoinedFallbackOffer(message.oderId, signalUrl, message.serverId);
}
} }
} }
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
if (message.oderId && message.oderId === this.dependencies.getLocalOderId()) {
return;
}
this.dependencies.logger.info('User left', { this.dependencies.logger.info('User left', {
displayName: message.displayName, displayName: message.displayName,
oderId: message.oderId, oderId: message.oderId,
@@ -192,10 +285,13 @@ export class IncomingSignalingMessageHandler {
}); });
if (message.oderId) { if (message.oderId) {
this.clearUserJoinedFallbackOffer(message.oderId);
this.nonInitiatorWaitStart.delete(message.oderId);
const hasRemainingSharedServers = Array.isArray(message.serverIds) const hasRemainingSharedServers = Array.isArray(message.serverIds)
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, message.serverIds) ? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds)
: (message.serverId : (message.serverId
? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, message.serverId) ? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, signalUrl, message.serverId)
: false); : false);
if (!hasRemainingSharedServers) { if (!hasRemainingSharedServers) {
@@ -212,12 +308,18 @@ export class IncomingSignalingMessageHandler {
if (!fromUserId || !sdp) if (!fromUserId || !sdp)
return; return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
this.clearUserJoinedFallbackOffer(fromUserId);
this.nonInitiatorWaitStart.delete(fromUserId);
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl); this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
const effectiveServerId = this.dependencies.getEffectiveServerId(); const effectiveServerId = this.dependencies.getEffectiveServerId();
if (effectiveServerId && !this.dependencies.signalingCoordinator.hasTrackedPeerServers(fromUserId)) { if (effectiveServerId && !this.dependencies.signalingCoordinator.hasTrackedPeerServers(fromUserId)) {
this.dependencies.signalingCoordinator.trackPeerInServer(fromUserId, effectiveServerId); this.dependencies.signalingCoordinator.trackPeerInServer(fromUserId, effectiveServerId, signalUrl);
} }
this.dependencies.peerManager.handleOffer(fromUserId, sdp); this.dependencies.peerManager.handleOffer(fromUserId, sdp);
@@ -230,6 +332,11 @@ export class IncomingSignalingMessageHandler {
if (!fromUserId || !sdp) if (!fromUserId || !sdp)
return; return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
this.clearUserJoinedFallbackOffer(fromUserId);
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl); this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
this.dependencies.peerManager.handleAnswer(fromUserId, sdp); this.dependencies.peerManager.handleAnswer(fromUserId, sdp);
} }
@@ -241,16 +348,197 @@ export class IncomingSignalingMessageHandler {
if (!fromUserId || !candidate) if (!fromUserId || !candidate)
return; return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
this.clearUserJoinedFallbackOffer(fromUserId);
this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl); this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl);
this.dependencies.peerManager.handleIceCandidate(fromUserId, candidate); this.dependencies.peerManager.handleIceCandidate(fromUserId, candidate);
} }
private canReusePeerConnection(peer: PeerData | undefined): boolean { private scheduleUserJoinedFallbackOffer(peerId: string, signalUrl: string, serverId?: string): void {
this.clearUserJoinedFallbackOffer(peerId);
const timer = setTimeout(() => {
this.userJoinedFallbackTimers.delete(peerId);
const localOderId = this.dependencies.getLocalOderId();
const existing = this.dependencies.peerManager.activePeerConnections.get(peerId);
if (this.hasActivePeerConnection(existing)) {
this.nonInitiatorWaitStart.delete(peerId);
this.dependencies.logger.info('Skip user_joined fallback offer - peer already active', {
connectionState: existing?.connection.connectionState ?? 'unknown',
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
oderId: peerId,
serverId,
signalUrl
});
return;
}
if (!localOderId) {
this.dependencies.logger.info('Retrying peer initiation once logical identity is ready', {
oderId: peerId,
serverId,
signalUrl
});
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
return;
}
if (this.isPeerConnectionNegotiating(existing)) {
this.dependencies.logger.info('Delaying fallback offer while peer negotiation is still in progress', {
ageMs: existing ? Date.now() - existing.createdAt : undefined,
connectionState: existing?.connection.connectionState ?? 'unknown',
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
localOderId,
oderId: peerId,
serverId,
signalUrl
});
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
return;
}
const shouldInitiate = this.shouldInitiatePeer(peerId, localOderId);
if (!shouldInitiate) {
// Track how long we've been waiting for the remote initiator's offer.
if (!this.nonInitiatorWaitStart.has(peerId)) {
this.nonInitiatorWaitStart.set(peerId, Date.now());
}
const waitStart = this.nonInitiatorWaitStart.get(peerId) ?? Date.now();
const waitMs = Date.now() - waitStart;
if (waitMs < NON_INITIATOR_GIVE_UP_MS) {
this.dependencies.logger.info('Waiting for remote initiator offer', {
localOderId,
oderId: peerId,
serverId,
signalUrl,
waitMs
});
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
return;
}
// The elected initiator never sent an offer - take over.
this.nonInitiatorWaitStart.delete(peerId);
if (existing) {
this.dependencies.logger.info('Removing stale peer before non-initiator takeover offer', {
connectionState: existing.connection.connectionState,
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
localOderId,
oderId: peerId,
serverId,
signalUrl,
waitMs
});
this.dependencies.peerManager.removePeer(peerId);
}
this.dependencies.logger.info('Non-initiator takeover - creating peer connection after remote initiator timeout', {
localOderId,
oderId: peerId,
serverId,
signalUrl,
waitMs
});
this.dependencies.peerManager.createPeerConnection(peerId, true);
void this.dependencies.peerManager.createAndSendOffer(peerId);
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
return;
}
if (existing) {
this.dependencies.logger.info('Removing stale peer before user_joined fallback offer', {
connectionState: existing.connection.connectionState,
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
oderId: peerId,
serverId,
signalUrl
});
this.dependencies.peerManager.removePeer(peerId);
}
this.dependencies.logger.info('Create peer connection from user_joined fallback offer', {
oderId: peerId,
serverId,
signalUrl
});
this.dependencies.peerManager.createPeerConnection(peerId, true);
void this.dependencies.peerManager.createAndSendOffer(peerId);
this.scheduleUserJoinedFallbackOffer(peerId, signalUrl, serverId);
}, USER_JOINED_FALLBACK_OFFER_DELAY_MS);
this.userJoinedFallbackTimers.set(peerId, timer);
}
private clearUserJoinedFallbackOffer(peerId: string): void {
const timer = this.userJoinedFallbackTimers.get(peerId);
if (!timer) {
return;
}
clearTimeout(timer);
this.userJoinedFallbackTimers.delete(peerId);
}
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
if (!localOderId)
return false;
if (peerId === localOderId)
return false;
return localOderId < peerId;
}
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
if (!peer) if (!peer)
return false; return false;
const connectionState = peer.connection?.connectionState; const connectionState = peer.connection?.connectionState;
return connectionState !== 'closed' && connectionState !== 'failed'; return connectionState === 'connected' || peer.dataChannel?.readyState === 'open';
}
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
if (!peer || this.hasActivePeerConnection(peer))
return false;
const connectionState = peer.connection?.connectionState;
if (connectionState === 'closed' || connectionState === 'failed')
return false;
const signalingState = peer.connection?.signalingState;
const ageMs = Date.now() - peer.createdAt;
// If a local offer (or pranswer) has already been sent, the peer is actively
// negotiating with the remote side. Use a much longer grace period so that
// a slow signaling round-trip does not trigger a premature teardown.
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer')
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
// ICE negotiation in progress (offer/answer exchange already complete, candidates being checked).
// TURN relay can take 5-15 s on high-latency networks, so use the same extended grace.
if (connectionState === 'connecting')
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
return ageMs < PEER_NEGOTIATION_GRACE_MS;
} }
} }

View File

@@ -95,6 +95,7 @@ export class SignalingTransportHandler<TMessage> {
sendRawMessage(message: Record<string, unknown>): void { sendRawMessage(message: Record<string, unknown>): void {
const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null; const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null;
const messageType = typeof message['type'] === 'string' ? message['type'] : 'unknown';
if (targetPeerId) { if (targetPeerId) {
const targetSignalUrl = this.dependencies.signalingCoordinator.getPeerSignalUrl(targetPeerId); const targetSignalUrl = this.dependencies.signalingCoordinator.getPeerSignalUrl(targetPeerId);
@@ -102,6 +103,11 @@ export class SignalingTransportHandler<TMessage> {
if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) { if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) {
return; return;
} }
this.dependencies.logger.warn('[signaling] Missing peer signal route for outbound raw message', {
targetPeerId,
type: messageType
});
} }
const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null; const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null;
@@ -118,12 +124,19 @@ export class SignalingTransportHandler<TMessage> {
if (connectedManagers.length === 0) { if (connectedManagers.length === 0) {
this.dependencies.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), { this.dependencies.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
type: typeof message['type'] === 'string' ? message['type'] : 'unknown' type: messageType
}); });
return; return;
} }
this.dependencies.logger.warn('[signaling] Broadcasting raw message to all signaling managers due to unresolved route', {
connectedSignalUrls: connectedManagers.map(({ signalUrl }) => signalUrl),
serverId,
targetPeerId,
type: messageType
});
for (const { manager } of connectedManagers) { for (const { manager } of connectedManagers) {
manager.sendRawMessage(message); manager.sendRawMessage(message);
} }

View File

@@ -3,7 +3,11 @@
* Manages the WebSocket connection to the signaling server, * Manages the WebSocket connection to the signaling server,
* including automatic reconnection and heartbeats. * including automatic reconnection and heartbeats.
*/ */
import { Observable, Subject } from 'rxjs'; import {
Observable,
Subject,
of
} from 'rxjs';
import type { SignalingMessage } from '../../../shared-kernel'; import type { SignalingMessage } from '../../../shared-kernel';
import { recordDebugNetworkSignalingPayload } from '../logging/debug-network-metrics'; import { recordDebugNetworkSignalingPayload } from '../logging/debug-network-metrics';
import { IdentifyCredentials, JoinedServerInfo } from '../realtime.types'; import { IdentifyCredentials, JoinedServerInfo } from '../realtime.types';
@@ -54,19 +58,42 @@ export class SignalingManager {
/** Open (or re-open) a WebSocket to the signaling server. */ /** Open (or re-open) a WebSocket to the signaling server. */
connect(serverUrl: string): Observable<boolean> { connect(serverUrl: string): Observable<boolean> {
if (this.lastSignalingUrl === serverUrl) {
if (this.isSocketOpen()) {
return of(true);
}
if (this.isSocketConnecting()) {
return this.waitForOpen();
}
}
this.lastSignalingUrl = serverUrl; this.lastSignalingUrl = serverUrl;
return new Observable<boolean>((observer) => { return new Observable<boolean>((observer) => {
try { try {
this.logger.info('[signaling] Connecting to signaling server', { serverUrl }); this.logger.info('[signaling] Connecting to signaling server', { serverUrl });
if (this.signalingWebSocket) { const previousSocket = this.signalingWebSocket;
this.signalingWebSocket.close();
}
this.lastSignalingUrl = serverUrl; this.lastSignalingUrl = serverUrl;
this.signalingWebSocket = new WebSocket(serverUrl); const socket = new WebSocket(serverUrl);
this.signalingWebSocket = socket;
if (previousSocket && previousSocket !== socket) {
try {
previousSocket.close();
} catch {
this.logger.warn('[signaling] Failed to close previous signaling socket', {
url: serverUrl
});
}
}
socket.onopen = () => {
if (socket !== this.signalingWebSocket)
return;
this.signalingWebSocket.onopen = () => {
this.logger.info('[signaling] Connected to signaling server', { this.logger.info('[signaling] Connected to signaling server', {
serverUrl, serverUrl,
readyState: this.getSocketReadyStateLabel() readyState: this.getSocketReadyStateLabel()
@@ -77,9 +104,13 @@ export class SignalingManager {
this.connectionStatus$.next({ connected: true }); this.connectionStatus$.next({ connected: true });
this.reIdentifyAndRejoin(); this.reIdentifyAndRejoin();
observer.next(true); observer.next(true);
observer.complete();
}; };
this.signalingWebSocket.onmessage = (event) => { socket.onmessage = (event) => {
if (socket !== this.signalingWebSocket)
return;
const rawPayload = this.stringifySocketPayload(event.data); const rawPayload = this.stringifySocketPayload(event.data);
const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null; const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null;
@@ -109,7 +140,10 @@ export class SignalingManager {
} }
}; };
this.signalingWebSocket.onerror = (error) => { socket.onerror = (error) => {
if (socket !== this.signalingWebSocket)
return;
this.logger.error('[signaling] Signaling socket error', error, { this.logger.error('[signaling] Signaling socket error', error, {
readyState: this.getSocketReadyStateLabel(), readyState: this.getSocketReadyStateLabel(),
url: serverUrl url: serverUrl
@@ -121,7 +155,10 @@ export class SignalingManager {
observer.error(error); observer.error(error);
}; };
this.signalingWebSocket.onclose = (event) => { socket.onclose = (event) => {
if (socket !== this.signalingWebSocket)
return;
this.logger.warn('[signaling] Disconnected from signaling server', { this.logger.warn('[signaling] Disconnected from signaling server', {
attempts: this.signalingReconnectAttempts, attempts: this.signalingReconnectAttempts,
code: event.code, code: event.code,
@@ -216,9 +253,12 @@ export class SignalingManager {
this.stopHeartbeat(); this.stopHeartbeat();
this.clearReconnect(); this.clearReconnect();
if (this.signalingWebSocket) { const socket = this.signalingWebSocket;
this.signalingWebSocket.close();
this.signalingWebSocket = null; this.signalingWebSocket = null;
if (socket) {
socket.close();
} }
} }
@@ -227,6 +267,10 @@ export class SignalingManager {
return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.OPEN; return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.OPEN;
} }
isSocketConnecting(): boolean {
return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.CONNECTING;
}
/** The URL last used to connect (needed for reconnection). */ /** The URL last used to connect (needed for reconnection). */
getLastUrl(): string | null { getLastUrl(): string | null {
return this.lastSignalingUrl; return this.lastSignalingUrl;
@@ -273,7 +317,7 @@ export class SignalingManager {
* No-ops if a timer is already pending or no URL is stored. * No-ops if a timer is already pending or no URL is stored.
*/ */
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.signalingReconnectTimer || !this.lastSignalingUrl) if (this.signalingReconnectTimer || !this.lastSignalingUrl || this.isSocketOpen() || this.isSocketConnecting())
return; return;
const delay = Math.min( const delay = Math.min(
@@ -283,6 +327,11 @@ export class SignalingManager {
this.signalingReconnectTimer = setTimeout(() => { this.signalingReconnectTimer = setTimeout(() => {
this.signalingReconnectTimer = null; this.signalingReconnectTimer = null;
if (this.isSocketOpen() || this.isSocketConnecting()) {
return;
}
this.signalingReconnectAttempts++; this.signalingReconnectAttempts++;
this.logger.info('[signaling] Attempting reconnect', { this.logger.info('[signaling] Attempting reconnect', {
attempt: this.signalingReconnectAttempts, attempt: this.signalingReconnectAttempts,
@@ -297,6 +346,44 @@ export class SignalingManager {
}, delay); }, delay);
} }
private waitForOpen(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Observable<boolean> {
if (this.isSocketOpen()) {
return of(true);
}
return new Observable<boolean>((observer) => {
let settled = false;
const subscription = this.connectionStatus$.subscribe(({ connected }) => {
if (!connected || settled) {
return;
}
settled = true;
clearTimeout(timeout);
subscription.unsubscribe();
observer.next(true);
observer.complete();
});
const timeout = setTimeout(() => {
if (settled) {
return;
}
settled = true;
subscription.unsubscribe();
observer.next(this.isSocketOpen());
observer.complete();
}, timeoutMs);
return () => {
settled = true;
clearTimeout(timeout);
subscription.unsubscribe();
};
});
}
/** Cancel any pending reconnect timer and reset the attempt counter. */ /** Cancel any pending reconnect timer and reset the attempt counter. */
private clearReconnect(): void { private clearReconnect(): void {
if (this.signalingReconnectTimer) { if (this.signalingReconnectTimer) {
@@ -415,21 +502,23 @@ export class SignalingManager {
const record = payload as Record<string, unknown>; const record = payload as Record<string, unknown>;
const voiceState = this.summarizeVoiceState(record['voiceState']); const voiceState = this.summarizeVoiceState(record['voiceState']);
const users = this.summarizeUsers(record['users']); const users = this.summarizeUsers(record['users']);
const preview: Record<string, unknown> = {
return {
displayName: typeof record['displayName'] === 'string' ? record['displayName'] : undefined,
fromUserId: typeof record['fromUserId'] === 'string' ? record['fromUserId'] : undefined,
isScreenSharing: typeof record['isScreenSharing'] === 'boolean' ? record['isScreenSharing'] : undefined,
keys: Object.keys(record).slice(0, 10), keys: Object.keys(record).slice(0, 10),
oderId: typeof record['oderId'] === 'string' ? record['oderId'] : undefined, type: typeof record['type'] === 'string' ? record['type'] : 'unknown'
roomId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined,
serverId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined,
targetPeerId: typeof record['targetUserId'] === 'string' ? record['targetUserId'] : undefined,
type: typeof record['type'] === 'string' ? record['type'] : 'unknown',
userCount: Array.isArray(record['users']) ? record['users'].length : undefined,
users,
voiceState
}; };
this.assignPreviewValue(preview, 'displayName', typeof record['displayName'] === 'string' ? record['displayName'] : undefined);
this.assignPreviewValue(preview, 'fromUserId', typeof record['fromUserId'] === 'string' ? record['fromUserId'] : undefined);
this.assignPreviewValue(preview, 'isScreenSharing', typeof record['isScreenSharing'] === 'boolean' ? record['isScreenSharing'] : undefined);
this.assignPreviewValue(preview, 'oderId', typeof record['oderId'] === 'string' ? record['oderId'] : undefined);
this.assignPreviewValue(preview, 'roomId', typeof record['roomId'] === 'string' ? record['roomId'] : undefined);
this.assignPreviewValue(preview, 'serverId', typeof record['serverId'] === 'string' ? record['serverId'] : undefined);
this.assignPreviewValue(preview, 'targetPeerId', typeof record['targetUserId'] === 'string' ? record['targetUserId'] : undefined);
this.assignPreviewValue(preview, 'userCount', Array.isArray(record['users']) ? record['users'].length : undefined);
this.assignPreviewValue(preview, 'users', users);
this.assignPreviewValue(preview, 'voiceState', voiceState);
return preview;
} }
private summarizeVoiceState(value: unknown): Record<string, unknown> | undefined { private summarizeVoiceState(value: unknown): Record<string, unknown> | undefined {
@@ -438,15 +527,18 @@ export class SignalingManager {
if (!voiceState) if (!voiceState)
return undefined; return undefined;
return { const summary: Record<string, unknown> = {
isConnected: voiceState['isConnected'] === true, isConnected: voiceState['isConnected'] === true,
isMuted: voiceState['isMuted'] === true, isMuted: voiceState['isMuted'] === true,
isDeafened: voiceState['isDeafened'] === true, isDeafened: voiceState['isDeafened'] === true,
isSpeaking: voiceState['isSpeaking'] === true, isSpeaking: voiceState['isSpeaking'] === true
roomId: typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined,
serverId: typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined,
volume: typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined
}; };
this.assignPreviewValue(summary, 'roomId', typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined);
this.assignPreviewValue(summary, 'serverId', typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined);
this.assignPreviewValue(summary, 'volume', typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined);
return summary;
} }
private summarizeUsers(value: unknown): Record<string, unknown>[] | undefined { private summarizeUsers(value: unknown): Record<string, unknown>[] | undefined {
@@ -461,15 +553,22 @@ export class SignalingManager {
if (!user) if (!user)
continue; continue;
users.push({ const summary: Record<string, unknown> = {};
displayName: typeof user['displayName'] === 'string' ? user['displayName'] : undefined,
oderId: typeof user['oderId'] === 'string' ? user['oderId'] : undefined this.assignPreviewValue(summary, 'displayName', typeof user['displayName'] === 'string' ? user['displayName'] : undefined);
}); this.assignPreviewValue(summary, 'oderId', typeof user['oderId'] === 'string' ? user['oderId'] : undefined);
users.push(summary);
} }
return users; return users;
} }
private assignPreviewValue(target: Record<string, unknown>, key: string, value: unknown): void {
if (value !== undefined)
target[key] = value;
}
private asRecord(value: unknown): Record<string, unknown> | null { private asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) if (!value || typeof value !== 'object' || Array.isArray(value))
return null; return null;

View File

@@ -1,5 +1,14 @@
export const DELETED_MESSAGE_CONTENT = '[Message deleted]'; export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export interface LinkMetadata {
url: string;
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
failed?: boolean;
}
export interface Message { export interface Message {
id: string; id: string;
roomId: string; roomId: string;
@@ -12,6 +21,7 @@ export interface Message {
reactions: Reaction[]; reactions: Reaction[];
isDeleted: boolean; isDeleted: boolean;
replyToId?: string; replyToId?: string;
linkMetadata?: LinkMetadata[];
} }
export interface Reaction { export interface Reaction {

View File

@@ -21,6 +21,7 @@ export interface User {
isOnline?: boolean; isOnline?: boolean;
isAdmin?: boolean; isAdmin?: boolean;
isRoomOwner?: boolean; isRoomOwner?: boolean;
presenceServerIds?: string[];
voiceState?: VoiceState; voiceState?: VoiceState;
screenShareState?: ScreenShareState; screenShareState?: ScreenShareState;
cameraState?: CameraState; cameraState?: CameraState;

View File

@@ -3,6 +3,7 @@ import {
ElementRef, ElementRef,
effect, effect,
input, input,
output,
signal, signal,
viewChild viewChild
} from '@angular/core'; } from '@angular/core';
@@ -30,6 +31,7 @@ import { type DebugLogEntry, type DebugLogLevel } from '../../../../core/service
export class DebugConsoleEntryListComponent { export class DebugConsoleEntryListComponent {
readonly entries = input.required<DebugLogEntry[]>(); readonly entries = input.required<DebugLogEntry[]>();
readonly autoScroll = input.required<boolean>(); readonly autoScroll = input.required<boolean>();
readonly entryExpanded = output<void>();
readonly expandedEntryIds = signal<number[]>([]); readonly expandedEntryIds = signal<number[]>([]);
private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('viewport'); private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('viewport');
@@ -52,6 +54,7 @@ export class DebugConsoleEntryListComponent {
nextExpandedIds.delete(entryId); nextExpandedIds.delete(entryId);
} else { } else {
nextExpandedIds.add(entryId); nextExpandedIds.add(entryId);
this.entryExpanded.emit();
} }
this.expandedEntryIds.set(Array.from(nextExpandedIds)); this.expandedEntryIds.set(Array.from(nextExpandedIds));

View File

@@ -173,10 +173,25 @@
/> />
@if (activeTab() === 'logs') { @if (activeTab() === 'logs') {
@if (isTruncated()) {
<div class="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-1.5">
<span class="text-xs text-muted-foreground">
Showing latest 500 of {{ filteredEntries().length }} entries
</span>
<button
type="button"
class="text-xs font-medium text-primary hover:underline"
(click)="toggleShowAllEntries()"
>
Show all
</button>
</div>
}
<app-debug-console-entry-list <app-debug-console-entry-list
class="min-h-0 flex-1 overflow-hidden" class="min-h-0 flex-1 overflow-hidden"
[entries]="filteredEntries()" [entries]="visibleEntries()"
[autoScroll]="autoScroll()" [autoScroll]="autoScroll()"
(entryExpanded)="disableAutoScroll()"
/> />
} @else { } @else {
<app-debug-console-network-map <app-debug-console-network-map

View File

@@ -62,6 +62,7 @@ export class DebugConsoleComponent {
readonly searchTerm = signal(''); readonly searchTerm = signal('');
readonly selectedSource = signal('all'); readonly selectedSource = signal('all');
readonly autoScroll = signal(true); readonly autoScroll = signal(true);
readonly showAllEntries = signal(false);
readonly panelHeight = this.resizeService.panelHeight; readonly panelHeight = this.resizeService.panelHeight;
readonly panelWidth = this.resizeService.panelWidth; readonly panelWidth = this.resizeService.panelWidth;
readonly panelLeft = this.resizeService.panelLeft; readonly panelLeft = this.resizeService.panelLeft;
@@ -77,6 +78,8 @@ export class DebugConsoleComponent {
readonly sourceOptions = computed(() => { readonly sourceOptions = computed(() => {
return Array.from(new Set(this.entries().map((entry) => entry.source))).sort(); return Array.from(new Set(this.entries().map((entry) => entry.source))).sort();
}); });
private static readonly VISIBLE_ENTRY_LIMIT = 500;
readonly filteredEntries = computed(() => { readonly filteredEntries = computed(() => {
const searchTerm = this.searchTerm().trim() const searchTerm = this.searchTerm().trim()
.toLowerCase(); .toLowerCase();
@@ -103,6 +106,17 @@ export class DebugConsoleComponent {
].some((value) => value.toLowerCase().includes(searchTerm)); ].some((value) => value.toLowerCase().includes(searchTerm));
}); });
}); });
readonly visibleEntries = computed(() => {
const all = this.filteredEntries();
if (this.showAllEntries() || all.length <= DebugConsoleComponent.VISIBLE_ENTRY_LIMIT)
return all;
return all.slice(-DebugConsoleComponent.VISIBLE_ENTRY_LIMIT);
});
readonly isTruncated = computed(() => {
return !this.showAllEntries() && this.filteredEntries().length > DebugConsoleComponent.VISIBLE_ENTRY_LIMIT;
});
readonly levelCounts = computed<Record<DebugLogLevel, number>>(() => { readonly levelCounts = computed<Record<DebugLogLevel, number>>(() => {
const counts: Record<DebugLogLevel, number> = { const counts: Record<DebugLogLevel, number> = {
event: 0, event: 0,
@@ -119,7 +133,7 @@ export class DebugConsoleComponent {
return counts; return counts;
}); });
readonly visibleCount = computed(() => { readonly visibleCount = computed(() => {
return this.filteredEntries().reduce((sum, entry) => sum + entry.count, 0); return this.visibleEntries().reduce((sum, entry) => sum + entry.count, 0);
}); });
readonly badgeCount = computed(() => { readonly badgeCount = computed(() => {
const counts = this.levelCounts(); const counts = this.levelCounts();
@@ -221,8 +235,17 @@ export class DebugConsoleComponent {
this.autoScroll.update((enabled) => !enabled); this.autoScroll.update((enabled) => !enabled);
} }
disableAutoScroll(): void {
this.autoScroll.set(false);
}
clearLogs(): void { clearLogs(): void {
this.debugging.clear(); this.debugging.clear();
this.showAllEntries.set(false);
}
toggleShowAllEntries(): void {
this.showAllEntries.update((v) => !v);
} }
startTopResize(event: MouseEvent): void { startTopResize(event: MouseEvent): void {

View File

@@ -9,7 +9,11 @@ import {
emptyProps, emptyProps,
props props
} from '@ngrx/store'; } from '@ngrx/store';
import { Message, Reaction } from '../../shared-kernel'; import {
Message,
Reaction,
LinkMetadata
} from '../../shared-kernel';
export const MessagesActions = createActionGroup({ export const MessagesActions = createActionGroup({
source: 'Messages', source: 'Messages',
@@ -49,6 +53,12 @@ export const MessagesActions = createActionGroup({
/** Marks the end of a message sync cycle. */ /** Marks the end of a message sync cycle. */
'Sync Complete': emptyProps(), 'Sync Complete': emptyProps(),
/** Attaches fetched link metadata to a message. */
'Update Link Metadata': props<{ messageId: string; linkMetadata: LinkMetadata[] }>(),
/** Removes a single link embed from a message by URL. */
'Remove Link Embed': props<{ messageId: string; url: string }>(),
/** Removes all messages from the store (e.g. when leaving a room). */ /** Removes all messages from the store (e.g. when leaving a room). */
'Clear Messages': emptyProps() 'Clear Messages': emptyProps()
} }

View File

@@ -31,11 +31,13 @@ import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentUser } from '../users/users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { selectMessagesEntities } from './messages.selectors';
import { RealtimeSessionFacade } from '../../core/realtime'; import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
import { DebuggingService } from '../../core/services'; import { DebuggingService } from '../../core/services';
import { AttachmentFacade } from '../../domains/attachment'; import { AttachmentFacade } from '../../domains/attachment';
import { LinkMetadataService } from '../../domains/chat/application/link-metadata.service';
import { TimeSyncService } from '../../core/services/time-sync.service'; import { TimeSyncService } from '../../core/services/time-sync.service';
import { import {
DELETED_MESSAGE_CONTENT, DELETED_MESSAGE_CONTENT,
@@ -56,6 +58,7 @@ export class MessagesEffects {
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly timeSync = inject(TimeSyncService); private readonly timeSync = inject(TimeSyncService);
private readonly linkMetadata = inject(LinkMetadataService);
/** Loads messages for a room from the local database, hydrating reactions. */ /** Loads messages for a room from the local database, hydrating reactions. */
loadMessages$ = createEffect(() => loadMessages$ = createEffect(() =>
@@ -374,6 +377,76 @@ export class MessagesEffects {
) )
); );
/**
* Fetches link metadata for newly sent or received messages that
* contain URLs but don't already have metadata attached.
*/
fetchLinkMetadata$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.sendMessageSuccess, MessagesActions.receiveMessage),
mergeMap(({ message }) => {
if (message.isDeleted || message.linkMetadata?.length)
return EMPTY;
const urls = this.linkMetadata.extractUrls(message.content);
if (urls.length === 0)
return EMPTY;
return from(this.linkMetadata.fetchAllMetadata(urls)).pipe(
mergeMap((metadata) => {
const meaningful = metadata.filter((md) => !md.failed);
if (meaningful.length === 0)
return EMPTY;
this.trackBackgroundOperation(
this.db.updateMessage(message.id, { linkMetadata: meaningful }),
'Failed to persist link metadata',
{ messageId: message.id }
);
return of(MessagesActions.updateLinkMetadata({
messageId: message.id,
linkMetadata: meaningful
}));
}),
catchError(() => EMPTY)
);
})
)
);
/**
* Removes a single link embed from a message, persists the change,
* and updates the store.
*/
removeLinkEmbed$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.removeLinkEmbed),
withLatestFrom(this.store.select(selectMessagesEntities)),
mergeMap(([{ messageId, url }, entities]) => {
const message = entities[messageId];
if (!message?.linkMetadata)
return EMPTY;
const remaining = message.linkMetadata.filter((meta) => meta.url !== url);
this.trackBackgroundOperation(
this.db.updateMessage(messageId, { linkMetadata: remaining.length ? remaining : undefined }),
'Failed to persist link embed removal',
{ messageId }
);
return of(MessagesActions.updateLinkMetadata({
messageId,
linkMetadata: remaining
}));
})
)
);
/** /**
* Central dispatcher for all incoming P2P messages. * Central dispatcher for all incoming P2P messages.
* Delegates to handler functions in `messages-incoming.handlers.ts`. * Delegates to handler functions in `messages-incoming.handlers.ts`.

View File

@@ -206,6 +206,17 @@ export const messagesReducer = createReducer(
}); });
}), }),
// Update link metadata on a message
on(MessagesActions.updateLinkMetadata, (state, { messageId, linkMetadata }) =>
messagesAdapter.updateOne(
{
id: messageId,
changes: { linkMetadata }
},
state
)
),
// Clear messages // Clear messages
on(MessagesActions.clearMessages, (state) => on(MessagesActions.clearMessages, (state) =>
messagesAdapter.removeAll({ messagesAdapter.removeAll({

View File

@@ -416,7 +416,10 @@ export class RoomMembersSyncEffects {
if (currentRoom?.id === room.id && departedUserId) { if (currentRoom?.id === room.id && departedUserId) {
actions.push( actions.push(
UsersActions.userLeft({ userId: departedUserId }) UsersActions.userLeft({
userId: departedUserId,
serverId: room.id
})
); );
} }

View File

@@ -64,7 +64,7 @@ import {
} from '../../shared-kernel'; } from '../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import { ROOM_URL_PATTERN } from '../../core/constants'; import { RECONNECT_SOUND_GRACE_MS, ROOM_URL_PATTERN } from '../../core/constants';
import { VoiceSessionFacade } from '../../domains/voice-session'; import { VoiceSessionFacade } from '../../domains/voice-session';
import { import {
findRoomMember, findRoomMember,
@@ -163,6 +163,7 @@ interface RoomPresenceSignalingMessage {
type: string; type: string;
reason?: string; reason?: string;
serverId?: string; serverId?: string;
serverIds?: string[];
users?: { oderId: string; displayName: string }[]; users?: { oderId: string; displayName: string }[];
oderId?: string; oderId?: string;
displayName?: string; displayName?: string;
@@ -185,10 +186,16 @@ export class RoomsEffects {
/** /**
* Tracks user IDs we already know are in voice. Lives outside the * Tracks user IDs we already know are in voice. Lives outside the
* NgRx store so it survives `clearUsers()` dispatched on server switches * NgRx store so it survives room switches and presence re-syncs,
* and prevents false join/leave sounds during state re-syncs. * preventing false join/leave sounds during state refreshes.
*/ */
private knownVoiceUsers = new Set<string>(); private knownVoiceUsers = new Set<string>();
/**
* When a user leaves (e.g. socket drops), record the timestamp so
* that a rapid re-join (reconnect) does not trigger a false
* join/leave sound within {@link RECONNECT_SOUND_GRACE_MS}.
*/
private recentlyLeftVoiceTimestamps = new Map<string, number>();
private roomNavigationRequestVersion = 0; private roomNavigationRequestVersion = 0;
private latestNavigatedRoomId: string | null = null; private latestNavigatedRoomId: string | null = null;
@@ -696,15 +703,11 @@ export class RoomsEffects {
) )
); );
/** Reloads messages and users when the viewed server changes. */ /** Reloads messages and bans when the viewed server changes. */
onViewServerSuccess$ = createEffect(() => onViewServerSuccess$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.viewServerSuccess), ofType(RoomsActions.viewServerSuccess),
mergeMap(({ room }) => [ mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
UsersActions.clearUsers(),
MessagesActions.loadMessages({ roomId: room.id }),
UsersActions.loadBans()
])
) )
); );
@@ -1199,52 +1202,63 @@ export class RoomsEffects {
) )
); );
/** Clears messages and users from the store when leaving a room. */ /** Clears viewed messages when leaving a room. */
onLeaveRoom$ = createEffect(() => onLeaveRoom$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.leaveRoomSuccess), ofType(RoomsActions.leaveRoomSuccess),
mergeMap(() => { mergeMap(() => [MessagesActions.clearMessages()])
this.knownVoiceUsers.clear();
return [MessagesActions.clearMessages(), UsersActions.clearUsers()];
})
) )
); );
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */ /** Handles WebRTC signaling events for user presence (join, leave, server_users). */
signalingMessages$ = createEffect(() => signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe( this.webrtc.onSignalingMessage.pipe(
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([ mergeMap(([
message, message,
currentUser, currentUser,
currentRoom currentRoom,
savedRooms
]) => { ]) => {
const signalingMessage: RoomPresenceSignalingMessage = message; const signalingMessage: RoomPresenceSignalingMessage = message;
const myId = currentUser?.oderId || currentUser?.id; const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id; const viewedServerId = currentRoom?.id;
const room = this.resolveRoom(signalingMessage.serverId, currentRoom, savedRooms);
const shouldClearReconnectFlag = !isWrongServer(signalingMessage.serverId, viewedServerId);
switch (signalingMessage.type) { switch (signalingMessage.type) {
case 'server_users': { case 'server_users': {
if (!signalingMessage.users || isWrongServer(signalingMessage.serverId, viewedServerId)) if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
return EMPTY; return EMPTY;
const joinActions = signalingMessage.users const syncedUsers = signalingMessage.users
.filter((u) => u.oderId !== myId) .filter((u) => u.oderId !== myId)
.map((u) => .map((u) =>
UsersActions.userJoined({ buildSignalingUser(u, {
user: buildSignalingUser(u, buildKnownUserExtras(currentRoom, u.oderId)) ...buildKnownUserExtras(room, u.oderId),
presenceServerIds: [signalingMessage.serverId]
}) })
); );
const actions: Action[] = [
return [ UsersActions.syncServerPresence({
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }), roomId: signalingMessage.serverId,
UsersActions.clearUsers(), users: syncedUsers
...joinActions })
]; ];
if (shouldClearReconnectFlag) {
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
}
return actions;
} }
case 'user_joined': { case 'user_joined': {
if (isWrongServer(signalingMessage.serverId, viewedServerId) || signalingMessage.oderId === myId) if (!signalingMessage.serverId || signalingMessage.oderId === myId)
return EMPTY; return EMPTY;
if (!signalingMessage.oderId) if (!signalingMessage.oderId)
@@ -1254,24 +1268,51 @@ export class RoomsEffects {
oderId: signalingMessage.oderId, oderId: signalingMessage.oderId,
displayName: signalingMessage.displayName displayName: signalingMessage.displayName
}; };
const actions: Action[] = [
return [
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId)) user: buildSignalingUser(joinedUser, {
...buildKnownUserExtras(room, joinedUser.oderId),
presenceServerIds: [signalingMessage.serverId]
})
}) })
]; ];
if (shouldClearReconnectFlag) {
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
}
return actions;
} }
case 'user_left': { case 'user_left': {
if (isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY;
if (!signalingMessage.oderId) if (!signalingMessage.oderId)
return EMPTY; return EMPTY;
const remainingServerIds = Array.isArray(signalingMessage.serverIds)
? signalingMessage.serverIds
: undefined;
if (!remainingServerIds || remainingServerIds.length === 0) {
if (this.knownVoiceUsers.has(signalingMessage.oderId)) {
this.recentlyLeftVoiceTimestamps.set(signalingMessage.oderId, Date.now());
}
this.knownVoiceUsers.delete(signalingMessage.oderId); this.knownVoiceUsers.delete(signalingMessage.oderId);
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: false }), UsersActions.userLeft({ userId: signalingMessage.oderId })]; }
const actions: Action[] = [
UsersActions.userLeft({
userId: signalingMessage.oderId,
serverId: signalingMessage.serverId,
serverIds: remainingServerIds
})
];
if (shouldClearReconnectFlag) {
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
}
return actions;
} }
case 'access_denied': { case 'access_denied': {
@@ -1354,13 +1395,13 @@ export class RoomsEffects {
]) => { ]) => {
switch (event.type) { switch (event.type) {
case 'voice-state': case 'voice-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice') : EMPTY; return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
case 'voice-channel-move': case 'voice-channel-move':
return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null); return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null);
case 'screen-state': case 'screen-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen') : EMPTY; return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen');
case 'camera-state': case 'camera-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera') : EMPTY; return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera');
case 'server-state-request': case 'server-state-request':
return this.handleServerStateRequest(event, currentRoom, savedRooms); return this.handleServerStateRequest(event, currentRoom, savedRooms);
case 'server-state-full': case 'server-state-full':
@@ -1405,22 +1446,38 @@ export class RoomsEffects {
if (!vs) if (!vs)
return EMPTY; return EMPTY;
const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
? UsersActions.userJoined({
user: buildSignalingUser(
{ oderId: userId,
displayName: event.displayName || existingUser?.displayName || 'User' },
{ presenceServerIds: [vs.serverId] }
)
})
: null;
// Detect voice-connection transitions to play join/leave sounds. // Detect voice-connection transitions to play join/leave sounds.
// Use the local knownVoiceUsers set (not the store) so that // Use the local knownVoiceUsers set (not the store) so presence
// clearUsers() from server-switching doesn't create false transitions. // re-syncs and room switches do not create false transitions.
const weAreInVoice = this.webrtc.isVoiceConnected(); const weAreInVoice = this.webrtc.isVoiceConnected();
const nowConnected = vs.isConnected ?? false; const nowConnected = vs.isConnected ?? false;
const wasKnown = this.knownVoiceUsers.has(userId); const wasKnown = this.knownVoiceUsers.has(userId);
const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState); const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState);
const isInCurrentVoiceRoom = this.isSameVoiceRoom(vs, currentUser?.voiceState); // Merge with existing state so partial updates (e.g. mute toggle
// that omits roomId/serverId) don't look like a room change.
const mergedVoiceState = { ...existingUser?.voiceState, ...vs };
const isInCurrentVoiceRoom = this.isSameVoiceRoom(mergedVoiceState, currentUser?.voiceState);
if (weAreInVoice) { if (weAreInVoice) {
const isReconnect = this.consumeRecentLeave(userId);
if (!isReconnect) {
if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) { if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) {
this.audioService.play(AppSound.Joining); this.audioService.play(AppSound.Joining);
} else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) { } else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) {
this.audioService.play(AppSound.Leave); this.audioService.play(AppSound.Leave);
} }
} }
}
// Keep the tracking set in sync // Keep the tracking set in sync
if (nowConnected) { if (nowConnected) {
@@ -1436,6 +1493,7 @@ export class RoomsEffects {
{ oderId: userId, { oderId: userId,
displayName: event.displayName || 'User' }, displayName: event.displayName || 'User' },
{ {
presenceServerIds: vs.serverId ? [vs.serverId] : undefined,
voiceState: { voiceState: {
isConnected: vs.isConnected ?? false, isConnected: vs.isConnected ?? false,
isMuted: vs.isMuted ?? false, isMuted: vs.isMuted ?? false,
@@ -1452,8 +1510,16 @@ export class RoomsEffects {
); );
} }
return of(UsersActions.updateVoiceState({ userId, const actions: Action[] = [];
if (presenceRefreshAction) {
actions.push(presenceRefreshAction);
}
actions.push(UsersActions.updateVoiceState({ userId,
voiceState: vs })); voiceState: vs }));
return actions;
} }
if (kind === 'screen') { if (kind === 'screen') {
@@ -1581,6 +1647,31 @@ export class RoomsEffects {
&& voiceState.serverId === currentUserVoiceState.serverId; && voiceState.serverId === currentUserVoiceState.serverId;
} }
/**
* Returns `true` and cleans up the entry if the given user left
* recently enough to be considered a reconnect. Also prunes any
* stale entries older than the grace window.
*/
private consumeRecentLeave(userId: string): boolean {
const now = Date.now();
// Prune stale entries while iterating.
for (const [id, ts] of this.recentlyLeftVoiceTimestamps) {
if (now - ts > RECONNECT_SOUND_GRACE_MS) {
this.recentlyLeftVoiceTimestamps.delete(id);
}
}
const leaveTs = this.recentlyLeftVoiceTimestamps.get(userId);
if (leaveTs !== undefined && now - leaveTs <= RECONNECT_SOUND_GRACE_MS) {
this.recentlyLeftVoiceTimestamps.delete(userId);
return true;
}
return false;
}
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null { private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId) if (!roomId)
return currentRoom; return currentRoom;

View File

@@ -29,7 +29,8 @@ export const UsersActions = createActionGroup({
'Load Room Users Failure': props<{ error: string }>(), 'Load Room Users Failure': props<{ error: string }>(),
'User Joined': props<{ user: User }>(), 'User Joined': props<{ user: User }>(),
'User Left': props<{ userId: string }>(), 'User Left': props<{ userId: string; serverId?: string; serverIds?: string[] }>(),
'Sync Server Presence': props<{ roomId: string; users: User[] }>(),
'Update User': props<{ userId: string; updates: Partial<User> }>(), 'Update User': props<{ userId: string; updates: Partial<User> }>(),
'Update User Role': props<{ userId: string; role: User['role'] }>(), 'Update User Role': props<{ userId: string; role: User['role'] }>(),

View File

@@ -7,6 +7,105 @@ import {
import { User, BanEntry } from '../../shared-kernel'; import { User, BanEntry } from '../../shared-kernel';
import { UsersActions } from './users.actions'; import { UsersActions } from './users.actions';
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
if (!Array.isArray(serverIds)) {
return undefined;
}
const normalized = Array.from(new Set(
serverIds.filter((serverId): serverId is string => typeof serverId === 'string' && serverId.trim().length > 0)
));
return normalized.length > 0 ? normalized : undefined;
}
function mergePresenceServerIds(
existingServerIds: readonly string[] | undefined,
incomingServerIds: readonly string[] | undefined
): string[] | undefined {
return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]);
}
function buildDisconnectedVoiceState(user: User): User['voiceState'] {
if (!user.voiceState) {
return undefined;
}
return {
...user.voiceState,
isConnected: false,
isMuted: false,
isDeafened: false,
isSpeaking: false,
roomId: undefined,
serverId: undefined
};
}
function buildInactiveScreenShareState(user: User): User['screenShareState'] {
if (!user.screenShareState) {
return undefined;
}
return {
...user.screenShareState,
isSharing: false,
streamId: undefined,
sourceId: undefined,
sourceName: undefined
};
}
function buildInactiveCameraState(user: User): User['cameraState'] {
if (!user.cameraState) {
return undefined;
}
return {
...user.cameraState,
isEnabled: false
};
}
function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: User): User {
const presenceServerIds = mergePresenceServerIds(existingUser?.presenceServerIds, incomingUser.presenceServerIds);
const isOnline = (presenceServerIds?.length ?? 0) > 0 || incomingUser.isOnline === true;
const status = isOnline
? (incomingUser.status !== 'offline'
? incomingUser.status
: (existingUser?.status && existingUser.status !== 'offline' ? existingUser.status : 'online'))
: 'offline';
return {
...existingUser,
...incomingUser,
presenceServerIds,
isOnline,
status
};
}
function buildPresenceRemovalChanges(
user: User,
update: { serverId?: string; serverIds?: readonly string[] }
): Partial<User> {
const nextPresenceServerIds = update.serverIds !== undefined
? normalizePresenceServerIds(update.serverIds)
: normalizePresenceServerIds((user.presenceServerIds ?? []).filter((serverId) => serverId !== update.serverId));
const isOnline = (nextPresenceServerIds?.length ?? 0) > 0;
const shouldClearLiveState = !isOnline
|| (!!user.voiceState?.serverId && !nextPresenceServerIds?.includes(user.voiceState.serverId));
return {
presenceServerIds: nextPresenceServerIds,
isOnline,
status: isOnline ? (user.status !== 'offline' ? user.status : 'online') : 'offline',
voiceState: shouldClearLiveState ? buildDisconnectedVoiceState(user) : user.voiceState,
screenShareState: shouldClearLiveState ? buildInactiveScreenShareState(user) : user.screenShareState,
cameraState: shouldClearLiveState ? buildInactiveCameraState(user) : user.cameraState
};
}
export interface UsersState extends EntityState<User> { export interface UsersState extends EntityState<User> {
currentUserId: string | null; currentUserId: string | null;
hostId: string | null; hostId: string | null;
@@ -86,11 +185,61 @@ export const usersReducer = createReducer(
error error
})), })),
on(UsersActions.userJoined, (state, { user }) => on(UsersActions.userJoined, (state, { user }) =>
usersAdapter.upsertOne(user, state) usersAdapter.upsertOne(buildPresenceAwareUser(state.entities[user.id], user), state)
),
on(UsersActions.userLeft, (state, { userId }) =>
usersAdapter.removeOne(userId, state)
), ),
on(UsersActions.syncServerPresence, (state, { roomId, users }) => {
let nextState = state;
const seenUserIds = new Set<string>();
for (const user of users) {
seenUserIds.add(user.id);
nextState = usersAdapter.upsertOne(
buildPresenceAwareUser(nextState.entities[user.id], user),
nextState
);
}
const stalePresenceUpdates = Object.values(nextState.entities)
.filter((user): user is User =>
!!user
&& user.id !== nextState.currentUserId
&& user.presenceServerIds?.includes(roomId) === true
&& !seenUserIds.has(user.id)
)
.map((user) => ({
id: user.id,
changes: buildPresenceRemovalChanges(user, { serverId: roomId })
}));
return stalePresenceUpdates.length > 0
? usersAdapter.updateMany(stalePresenceUpdates, nextState)
: nextState;
}),
on(UsersActions.userLeft, (state, { userId, serverId, serverIds }) => {
const existingUser = state.entities[userId];
if (!existingUser) {
return (!serverId && !serverIds)
? usersAdapter.removeOne(userId, state)
: state;
}
if (!serverId && !serverIds) {
return usersAdapter.removeOne(userId, state);
}
return usersAdapter.updateOne(
{
id: userId,
changes: buildPresenceRemovalChanges(existingUser, {
serverId,
serverIds
})
},
state
);
}),
on(UsersActions.updateUser, (state, { userId, updates }) => on(UsersActions.updateUser, (state, { userId, updates }) =>
usersAdapter.updateOne( usersAdapter.updateOne(
{ {
@@ -171,6 +320,8 @@ export const usersReducer = createReducer(
isDeafened: false, isDeafened: false,
isSpeaking: false isSpeaking: false
}; };
const hasRoomId = Object.prototype.hasOwnProperty.call(voiceState, 'roomId');
const hasServerId = Object.prototype.hasOwnProperty.call(voiceState, 'serverId');
return usersAdapter.updateOne( return usersAdapter.updateOne(
{ {
@@ -183,9 +334,8 @@ export const usersReducer = createReducer(
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking, isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin, isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
volume: voiceState.volume ?? prev.volume, volume: voiceState.volume ?? prev.volume,
// Use explicit undefined check - if undefined is passed, clear the value roomId: hasRoomId ? voiceState.roomId : prev.roomId,
roomId: voiceState.roomId !== undefined ? voiceState.roomId : prev.roomId, serverId: hasServerId ? voiceState.serverId : prev.serverId
serverId: voiceState.serverId !== undefined ? voiceState.serverId : prev.serverId
} }
} }
}, },

View File

@@ -82,7 +82,13 @@ export const selectIsCurrentUserAdmin = createSelector(
/** Selects users who are currently online (not offline). */ /** Selects users who are currently online (not offline). */
export const selectOnlineUsers = createSelector( export const selectOnlineUsers = createSelector(
selectAllUsers, selectAllUsers,
(users) => users.filter((user) => user.status !== 'offline' || user.isOnline === true) (users) => users.filter((user) => {
if (Array.isArray(user.presenceServerIds)) {
return user.presenceServerIds.length > 0 || user.isOnline === true || user.status !== 'offline';
}
return user.status !== 'offline' || user.isOnline === true;
})
); );
/** Creates a selector that returns users with a specific role. */ /** Creates a selector that returns users with a specific role. */

View File

@@ -10,7 +10,7 @@
/> />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:;" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com;"
/> />
<link <link
rel="icon" rel="icon"