Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0865c2fe33 | |||
| 4a41de79d6 | |||
| 84fa45985a | |||
| 35352923a5 | |||
| b9df9c92f2 | |||
| 8674579b19 | |||
| de2d3300d4 |
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,4 +393,4 @@ export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
|
|||||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
292
server/src/routes/link-metadata.ts
Normal file
292
server/src/routes/link-metadata.ts
Normal 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(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(///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;
|
||||||
@@ -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') || '';
|
||||||
|
|||||||
119
server/src/routes/ssrf-guard.ts
Normal file
119
server/src/routes/ssrf-guard.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-[height,opacity] duration-100"
|
||||||
class="pointer-events-none absolute left-0 top-1/2 h-10 w-1 -translate-y-1/2 rounded-l-full bg-primary"
|
[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)"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const peerData: PeerData = {
|
peerData = {
|
||||||
connection,
|
connection,
|
||||||
dataChannel,
|
dataChannel,
|
||||||
|
createdAt: Date.now(),
|
||||||
isInitiator,
|
isInitiator,
|
||||||
pendingIceCandidates: [],
|
pendingIceCandidates: [],
|
||||||
audioSender: undefined,
|
audioSender: undefined,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
void handlers.createAndSendOffer(peerId);
|
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Waiting for remote reconnect offer based on deterministic initiator selection', {
|
||||||
|
localOderId,
|
||||||
|
peerId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestVoiceStateFromPeer(
|
export function requestVoiceStateFromPeer(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
const remainingServerIds = Array.isArray(signalingMessage.serverIds)
|
||||||
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: false }), UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
? 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,20 +1446,36 @@ 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) {
|
||||||
if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) {
|
const isReconnect = this.consumeRecentLeave(userId);
|
||||||
this.audioService.play(AppSound.Joining);
|
|
||||||
} else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) {
|
if (!isReconnect) {
|
||||||
this.audioService.play(AppSound.Leave);
|
if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) {
|
||||||
|
this.audioService.play(AppSound.Joining);
|
||||||
|
} else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) {
|
||||||
|
this.audioService.play(AppSound.Leave);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'] }>(),
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user