Disallow any types

This commit is contained in:
2026-03-09 23:02:52 +01:00
parent 3b1aab4985
commit dc6746c882
40 changed files with 961 additions and 476 deletions

View File

@@ -1,4 +1,8 @@
import { app, BrowserWindow, shell } from 'electron'; import {
app,
BrowserWindow,
shell
} from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';

View File

@@ -103,7 +103,7 @@ module.exports = tseslint.config(
] }], ] }],
'@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-invalid-this': 'error', '@typescript-eslint/no-invalid-this': 'error',
'@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/prefer-namespace-keyword': 'error', '@typescript-eslint/prefer-namespace-keyword': 'error',

View File

@@ -1,4 +1,4 @@
/* eslint-disable complexity, @typescript-eslint/no-explicit-any */ /* eslint-disable complexity */
import { Router } from 'express'; import { Router } from 'express';
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables'; import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
@@ -25,6 +25,28 @@ interface NormalizedKlipyGif {
height: number; height: number;
} }
interface KlipyGifVariants {
md?: unknown;
sm?: unknown;
xs?: unknown;
hd?: unknown;
}
interface KlipyGifItem {
type?: unknown;
slug?: unknown;
id?: unknown;
title?: unknown;
file?: KlipyGifVariants;
}
interface KlipyApiResponse {
data?: {
data?: unknown;
has_next?: unknown;
};
}
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined { function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
for (const value of values) { for (const value of values) {
if (value != null) if (value != null)
@@ -91,16 +113,38 @@ function pickGifMeta(sizeVariant: unknown): NormalizedMediaMeta | null {
return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp); return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp);
} }
function normalizeGifItem(item: any): NormalizedKlipyGif | null { function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext: boolean } {
if (!item || typeof item !== 'object' || item.type === 'ad') if (typeof payload !== 'object' || payload === null) {
return {
items: [],
hasNext: false
};
}
const response = payload as KlipyApiResponse;
const items = Array.isArray(response.data?.data) ? response.data.data : [];
return {
items,
hasNext: response.data?.has_next === true
};
}
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
if (!item || typeof item !== 'object')
return null; return null;
const lowVariant = pickFirst(item.file?.md, item.file?.sm, item.file?.xs, item.file?.hd); const gifItem = item as KlipyGifItem;
const highVariant = pickFirst(item.file?.hd, item.file?.md, item.file?.sm, item.file?.xs);
if (gifItem.type === 'ad')
return null;
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd);
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
const lowMeta = pickGifMeta(lowVariant); const lowMeta = pickGifMeta(lowVariant);
const highMeta = pickGifMeta(highVariant); const highMeta = pickGifMeta(highVariant);
const selectedMeta = highMeta ?? lowMeta; const selectedMeta = highMeta ?? lowMeta;
const slug = sanitizeString(item.slug) ?? sanitizeString(item.id); const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
if (!slug || !selectedMeta?.url) if (!slug || !selectedMeta?.url)
return null; return null;
@@ -108,7 +152,7 @@ function normalizeGifItem(item: any): NormalizedKlipyGif | null {
return { return {
id: slug, id: slug,
slug, slug,
title: sanitizeString(item.title), title: sanitizeString(gifItem.title),
url: selectedMeta.url, url: selectedMeta.url,
previewUrl: lowMeta?.url ?? selectedMeta.url, previewUrl: lowMeta?.url ?? selectedMeta.url,
width: selectedMeta.width ?? lowMeta?.width ?? 0, width: selectedMeta.width ?? lowMeta?.width ?? 0,
@@ -196,9 +240,7 @@ router.get('/klipy/gifs', async (req, res) => {
}); });
} }
const rawItems = Array.isArray((payload as any)?.data?.data) const { items: rawItems, hasNext } = extractKlipyResponseData(payload);
? (payload as any).data.data
: [];
const results = rawItems const results = rawItems
.map((item: unknown) => normalizeGifItem(item)) .map((item: unknown) => normalizeGifItem(item))
.filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item); .filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item);
@@ -206,7 +248,7 @@ router.get('/klipy/gifs', async (req, res) => {
res.json({ res.json({
enabled: true, enabled: true,
results, results,
hasNext: (payload as any)?.data?.has_next === true hasNext
}); });
} catch (error) { } catch (error) {
if ((error as { name?: string })?.name === 'AbortError') { if ((error as { name?: string })?.name === 'AbortError') {

View File

@@ -1,9 +1,9 @@
declare module 'sql.js'; declare module 'sql.js';
declare module 'sql.js' { declare module 'sql.js' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>; export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
export type Database = any; export type Database = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
export type Statement = any; export type Statement = any;
} }

View File

@@ -1,7 +1,4 @@
import { import { BanEntry, User } from '../models/index';
BanEntry,
User
} from '../models/index';
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined; type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;
@@ -42,7 +39,7 @@ export function isRoomBanMatch(
/** Return true when any active ban entry targets the provided user. */ /** Return true when any active ban entry targets the provided user. */
export function hasRoomBanForUser( export function hasRoomBanForUser(
bans: Array<Pick<BanEntry, 'userId' | 'oderId'>>, bans: Pick<BanEntry, 'userId' | 'oderId'>[],
user: BanAwareUser, user: BanAwareUser,
persistedUserId?: string | null persistedUserId?: string | null
): boolean { ): boolean {

View File

@@ -178,6 +178,17 @@ export type ChatEventType =
| 'room-settings-update' | 'room-settings-update'
| 'voice-state' | 'voice-state'
| 'chat-inventory-request' | 'chat-inventory-request'
| 'chat-inventory'
| 'chat-sync-request-ids'
| 'chat-sync-batch'
| 'chat-sync-summary'
| 'chat-sync-request'
| 'chat-sync-full'
| 'file-announce'
| 'file-chunk'
| 'file-request'
| 'file-cancel'
| 'file-not-found'
| 'member-roster-request' | 'member-roster-request'
| 'member-roster' | 'member-roster'
| 'member-leave' | 'member-leave'
@@ -197,6 +208,28 @@ export type ChatEventType =
| 'unban' | 'unban'
| 'channels-update'; | 'channels-update';
export interface ChatInventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
}
export interface ChatAttachmentAnnouncement {
id: string;
filename: string;
size: number;
mime: string;
isImage: boolean;
uploaderPeerId?: string;
}
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
messageId: string;
filePath?: string;
savedPath?: string;
}
/** Optional fields depend on `type`. */ /** Optional fields depend on `type`. */
export interface ChatEvent { export interface ChatEvent {
type: ChatEventType; type: ChatEventType;
@@ -204,10 +237,20 @@ export interface ChatEvent {
messageId?: string; messageId?: string;
message?: Message; message?: Message;
reaction?: Reaction; reaction?: Reaction;
data?: Partial<Message>; data?: string | Partial<Message>;
timestamp?: number; timestamp?: number;
targetUserId?: string; targetUserId?: string;
roomId?: string; roomId?: string;
items?: ChatInventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: Record<string, ChatAttachmentMeta[]>;
total?: number;
index?: number;
count?: number;
lastUpdated?: number;
file?: ChatAttachmentAnnouncement;
fileId?: string;
hostId?: string; hostId?: string;
hostOderId?: string; hostOderId?: string;
previousHostId?: string; previousHostId?: string;
@@ -226,6 +269,8 @@ export interface ChatEvent {
permissions?: Partial<RoomPermissions>; permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>; voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean; isScreenSharing?: boolean;
icon?: string;
iconUpdatedAt?: number;
role?: UserRole; role?: UserRole;
room?: Room; room?: Room;
channels?: Channel[]; channels?: Channel[];

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
import { import {
Injectable, Injectable,
inject, inject,
@@ -12,6 +12,11 @@ import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
import { DatabaseService } from './database.service'; import { DatabaseService } from './database.service';
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service'; import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
import type {
ChatAttachmentAnnouncement,
ChatAttachmentMeta,
ChatEvent
} from '../models/index';
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ /** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
@@ -37,26 +42,7 @@ const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file
/** /**
* Metadata describing a file attachment linked to a chat message. * Metadata describing a file attachment linked to a chat message.
*/ */
export interface AttachmentMeta { export type AttachmentMeta = ChatAttachmentMeta;
/** Unique attachment identifier. */
id: string;
/** ID of the parent message. */
messageId: string;
/** Original file name. */
filename: string;
/** File size in bytes. */
size: number;
/** MIME type (e.g. `image/png`). */
mime: string;
/** Whether the file is a raster/vector image. */
isImage: boolean;
/** Peer ID of the user who originally uploaded the file. */
uploaderPeerId?: string;
/** Electron-only: absolute path to the uploader's original file. */
filePath?: string;
/** Electron-only: disk-cache path where the file was saved locally. */
savedPath?: string;
}
/** /**
* Runtime representation of an attachment including download * Runtime representation of an attachment including download
@@ -79,6 +65,72 @@ export interface Attachment extends AttachmentMeta {
requestError?: string; requestError?: string;
} }
type FileAnnounceEvent = ChatEvent & {
type: 'file-announce';
messageId: string;
file: ChatAttachmentAnnouncement;
};
type FileChunkEvent = ChatEvent & {
type: 'file-chunk';
messageId: string;
fileId: string;
index: number;
total: number;
data: string;
fromPeerId?: string;
};
type FileRequestEvent = ChatEvent & {
type: 'file-request';
messageId: string;
fileId: string;
fromPeerId?: string;
};
type FileCancelEvent = ChatEvent & {
type: 'file-cancel';
messageId: string;
fileId: string;
fromPeerId?: string;
};
type FileNotFoundEvent = ChatEvent & {
type: 'file-not-found';
messageId: string;
fileId: string;
};
type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
interface FileChunkPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
total?: number;
data?: ChatEvent['data'];
}
type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
interface AttachmentElectronApi {
getAppDataPath?: () => Promise<string>;
fileExists?: (filePath: string) => Promise<boolean>;
readFile?: (filePath: string) => Promise<string>;
deleteFile?: (filePath: string) => Promise<boolean>;
ensureDir?: (dirPath: string) => Promise<boolean>;
writeFile?: (filePath: string, data: string) => Promise<boolean>;
}
type ElectronWindow = Window & {
electronAPI?: AttachmentElectronApi;
};
type LocalFileWithPath = File & {
path?: string;
};
/** /**
* Manages peer-to-peer file transfer, local persistence, and * Manages peer-to-peer file transfer, local persistence, and
* in-memory caching of file attachments linked to chat messages. * in-memory caching of file attachments linked to chat messages.
@@ -140,6 +192,10 @@ export class AttachmentService {
}); });
} }
private getElectronApi(): AttachmentElectronApi | undefined {
return (window as ElectronWindow).electronAPI;
}
/** Return the attachment list for a given message. */ /** Return the attachment list for a given message. */
getForMessage(messageId: string): Attachment[] { getForMessage(messageId: string): Attachment[] {
return this.attachmentsByMessage.get(messageId) ?? []; return this.attachmentsByMessage.get(messageId) ?? [];
@@ -285,7 +341,7 @@ export class AttachmentService {
/** /**
* Handle a `file-not-found` response - try the next available peer. * Handle a `file-not-found` response - try the next available peer.
*/ */
handleFileNotFound(payload: any): void { handleFileNotFound(payload: FileNotFoundPayload): void {
const { messageId, fileId } = payload; const { messageId, fileId } = payload;
if (!messageId || !fileId) if (!messageId || !fileId)
@@ -345,7 +401,7 @@ export class AttachmentService {
mime: file.type || DEFAULT_MIME_TYPE, mime: file.type || DEFAULT_MIME_TYPE,
isImage: file.type.startsWith('image/'), isImage: file.type.startsWith('image/'),
uploaderPeerId, uploaderPeerId,
filePath: (file as any)?.path, filePath: (file as LocalFileWithPath).path,
available: false available: false
}; };
@@ -366,7 +422,7 @@ export class AttachmentService {
} }
// Broadcast metadata to peers // Broadcast metadata to peers
this.webrtc.broadcastMessage({ const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce', type: 'file-announce',
messageId, messageId,
file: { file: {
@@ -377,7 +433,9 @@ export class AttachmentService {
isImage: attachment.isImage, isImage: attachment.isImage,
uploaderPeerId uploaderPeerId
} }
} as any); };
this.webrtc.broadcastMessage(fileAnnounceEvent);
// Auto-stream small inline-preview media // Auto-stream small inline-preview media
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
@@ -396,7 +454,7 @@ export class AttachmentService {
} }
/** Handle a `file-announce` event from a peer. */ /** Handle a `file-announce` event from a peer. */
handleFileAnnounce(payload: any): void { handleFileAnnounce(payload: FileAnnouncePayload): void {
const { messageId, file } = payload; const { messageId, file } = payload;
if (!messageId || !file) if (!messageId || !file)
@@ -433,14 +491,14 @@ export class AttachmentService {
* expected count is reached, at which point the buffers are * expected count is reached, at which point the buffers are
* assembled into a Blob and an object URL is created. * assembled into a Blob and an object URL is created.
*/ */
handleFileChunk(payload: any): void { handleFileChunk(payload: FileChunkPayload): void {
const { messageId, fileId, fromPeerId, index, total, data } = payload; const { messageId, fileId, fromPeerId, index, total, data } = payload;
if ( if (
!messageId || !fileId || !messageId || !fileId ||
typeof index !== 'number' || typeof index !== 'number' ||
typeof total !== 'number' || typeof total !== 'number' ||
!data typeof data !== 'string'
) )
return; return;
@@ -538,7 +596,7 @@ export class AttachmentService {
* If none of these sources has the file, a `file-not-found` * If none of these sources has the file, a `file-not-found`
* message is sent so the requester can try another peer. * message is sent so the requester can try another peer.
*/ */
async handleFileRequest(payload: any): Promise<void> { async handleFileRequest(payload: FileRequestPayload): Promise<void> {
const { messageId, fileId, fromPeerId } = payload; const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId) if (!messageId || !fileId || !fromPeerId)
@@ -566,7 +624,7 @@ export class AttachmentService {
const list = this.attachmentsByMessage.get(messageId) ?? []; const list = this.attachmentsByMessage.get(messageId) ?? [];
const attachment = list.find((entry) => entry.id === fileId); const attachment = list.find((entry) => entry.id === fileId);
const electronApi = (window as any)?.electronAPI; const electronApi = this.getElectronApi();
// 2. Electron filePath // 2. Electron filePath
if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) { if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) {
@@ -619,11 +677,13 @@ export class AttachmentService {
} }
// 5. File not available locally // 5. File not available locally
this.webrtc.sendToPeer(fromPeerId, { const fileNotFoundEvent: FileNotFoundEvent = {
type: 'file-not-found', type: 'file-not-found',
messageId, messageId,
fileId fileId
} as any); };
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
} }
/** /**
@@ -658,11 +718,13 @@ export class AttachmentService {
this.touch(); this.touch();
// Notify uploader to stop streaming // Notify uploader to stop streaming
this.webrtc.sendToPeer(targetPeerId, { const fileCancelEvent: FileCancelEvent = {
type: 'file-cancel', type: 'file-cancel',
messageId, messageId,
fileId: attachment.id fileId: attachment.id
} as any); };
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
} catch { /* best-effort */ } } catch { /* best-effort */ }
} }
@@ -670,7 +732,7 @@ export class AttachmentService {
* Handle a `file-cancel` from the requester - record the * Handle a `file-cancel` from the requester - record the
* cancellation so the streaming loop breaks early. * cancellation so the streaming loop breaks early.
*/ */
handleFileCancel(payload: any): void { handleFileCancel(payload: FileCancelPayload): void {
const { messageId, fileId, fromPeerId } = payload; const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId) if (!messageId || !fileId || !fromPeerId)
@@ -774,7 +836,7 @@ export class AttachmentService {
} }
private async deleteSavedFile(filePath: string): Promise<void> { private async deleteSavedFile(filePath: string): Promise<void> {
const electronApi = (window as any)?.electronAPI; const electronApi = this.getElectronApi();
if (!electronApi?.deleteFile) if (!electronApi?.deleteFile)
return; return;
@@ -842,11 +904,13 @@ export class AttachmentService {
triedPeers.add(targetPeerId); triedPeers.add(targetPeerId);
this.pendingRequests.set(requestKey, triedPeers); this.pendingRequests.set(requestKey, triedPeers);
this.webrtc.sendToPeer(targetPeerId, { const fileRequestEvent: FileRequestEvent = {
type: 'file-request', type: 'file-request',
messageId, messageId,
fileId fileId
} as any); };
this.webrtc.sendToPeer(targetPeerId, fileRequestEvent);
return true; return true;
} }
@@ -866,15 +930,16 @@ export class AttachmentService {
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer(); const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer); const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = {
this.webrtc.broadcastMessage({
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
fileId, fileId,
index: chunkIndex, index: chunkIndex,
total: totalChunks, total: totalChunks,
data: base64 data: base64
} as any); };
this.webrtc.broadcastMessage(fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES; offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++; chunkIndex++;
@@ -900,15 +965,16 @@ export class AttachmentService {
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer(); const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer); const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = {
await this.webrtc.sendToPeerBuffered(targetPeerId, {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
fileId, fileId,
index: chunkIndex, index: chunkIndex,
total: totalChunks, total: totalChunks,
data: base64 data: base64
} as any); };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES; offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++; chunkIndex++;
@@ -925,7 +991,11 @@ export class AttachmentService {
fileId: string, fileId: string,
diskPath: string diskPath: string
): Promise<void> { ): Promise<void> {
const electronApi = (window as any)?.electronAPI; const electronApi = this.getElectronApi();
if (!electronApi?.readFile)
return;
const base64Full = await electronApi.readFile(diskPath); const base64Full = await electronApi.readFile(diskPath);
const fileBytes = this.base64ToUint8Array(base64Full); const fileBytes = this.base64ToUint8Array(base64Full);
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
@@ -942,15 +1012,16 @@ export class AttachmentService {
slice.byteOffset + slice.byteLength slice.byteOffset + slice.byteLength
); );
const base64Chunk = this.arrayBufferToBase64(sliceBuffer); const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
const fileChunkEvent: FileChunkEvent = {
this.webrtc.sendToPeer(targetPeerId, {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
fileId, fileId,
index: chunkIndex, index: chunkIndex,
total: totalChunks, total: totalChunks,
data: base64Chunk data: base64Chunk
} as any); };
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
} }
} }
@@ -960,10 +1031,10 @@ export class AttachmentService {
*/ */
private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> { private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
try { try {
const electronApi = (window as any)?.electronAPI; const electronApi = this.getElectronApi();
const appDataPath: string | undefined = await electronApi?.getAppDataPath?.(); const appDataPath: string | undefined = await electronApi?.getAppDataPath?.();
if (!appDataPath) if (!appDataPath || !electronApi?.ensureDir || !electronApi.writeFile)
return; return;
const roomName = await this.resolveCurrentRoomName(); const roomName = await this.resolveCurrentRoomName();
@@ -992,7 +1063,7 @@ export class AttachmentService {
/** On startup, try loading previously saved files from disk (Electron). */ /** On startup, try loading previously saved files from disk (Electron). */
private async tryLoadSavedFiles(): Promise<void> { private async tryLoadSavedFiles(): Promise<void> {
const electronApi = (window as any)?.electronAPI; const electronApi = this.getElectronApi();
if (!electronApi?.fileExists || !electronApi?.readFile) if (!electronApi?.fileExists || !electronApi?.readFile)
return; return;

View File

@@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* eslint-disable, @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
DELETED_MESSAGE_CONTENT, DELETED_MESSAGE_CONTENT,
ChatAttachmentMeta,
Message, Message,
User, User,
Room, Room,
@@ -234,10 +235,7 @@ export class BrowserDatabaseService {
const match = allBans.find((ban) => ban.oderId === oderId); const match = allBans.find((ban) => ban.oderId === oderId);
if (match) { if (match) {
await this.deleteRecord( await this.deleteRecord(STORE_BANS, match.oderId);
STORE_BANS,
(match as any).id ?? match.oderId
);
} }
} }
@@ -265,23 +263,23 @@ export class BrowserDatabaseService {
} }
/** Persist an attachment metadata record. */ /** Persist an attachment metadata record. */
async saveAttachment(attachment: any): Promise<void> { async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
await this.put(STORE_ATTACHMENTS, attachment); await this.put(STORE_ATTACHMENTS, attachment);
} }
/** Return all attachment records for a message. */ /** Return all attachment records for a message. */
async getAttachmentsForMessage(messageId: string): Promise<any[]> { async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
return this.getAllFromIndex<any>(STORE_ATTACHMENTS, 'messageId', messageId); return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
} }
/** Return every persisted attachment record. */ /** Return every persisted attachment record. */
async getAllAttachments(): Promise<any[]> { async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
return this.getAll<any>(STORE_ATTACHMENTS); return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
} }
/** Delete all attachment records for a message. */ /** Delete all attachment records for a message. */
async deleteAttachmentsForMessage(messageId: string): Promise<void> { async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const attachments = await this.getAllFromIndex<any>( const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
STORE_ATTACHMENTS, 'messageId', messageId STORE_ATTACHMENTS, 'messageId', messageId
); );
@@ -308,9 +306,7 @@ export class BrowserDatabaseService {
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
// ══════════════════════════════════════════════════════════════════
// Private helpers - thin wrappers around IndexedDB // Private helpers - thin wrappers around IndexedDB
// ══════════════════════════════════════════════════════════════════
/** /**
* Open (or upgrade) the IndexedDB database and create any missing * Open (or upgrade) the IndexedDB database and create any missing
@@ -370,7 +366,15 @@ export class BrowserDatabaseService {
stores: string | string[], stores: string | string[],
mode: IDBTransactionMode = 'readonly' mode: IDBTransactionMode = 'readonly'
): IDBTransaction { ): IDBTransaction {
return this.database!.transaction(stores, mode); return this.getDatabase().transaction(stores, mode);
}
private getDatabase(): IDBDatabase {
if (!this.database) {
throw new Error('Browser database is not initialized');
}
return this.database;
} }
/** Wrap a transaction's completion event as a Promise. */ /** Wrap a transaction's completion event as a Promise. */
@@ -420,7 +424,7 @@ export class BrowserDatabaseService {
} }
/** Insert or update a record in the given object store. */ /** Insert or update a record in the given object store. */
private put(storeName: string, value: any): Promise<void> { private put<T>(storeName: string, value: T): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName, 'readwrite'); const transaction = this.createTransaction(storeName, 'readwrite');

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, */
import { import {
inject, inject,
Injectable, Injectable,
@@ -9,7 +9,8 @@ import {
User, User,
Room, Room,
Reaction, Reaction,
BanEntry BanEntry,
ChatAttachmentMeta
} from '../models/index'; } from '../models/index';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { BrowserDatabaseService } from './browser-database.service'; import { BrowserDatabaseService } from './browser-database.service';
@@ -118,7 +119,7 @@ export class DatabaseService {
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); } isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
/** Persist attachment metadata. */ /** Persist attachment metadata. */
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); } saveAttachment(attachment: ChatAttachmentMeta) { return this.backend.saveAttachment(attachment); }
/** Return all attachment records for a message. */ /** Return all attachment records for a message. */
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); } getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }

View File

@@ -28,7 +28,7 @@ interface ElectronAPI {
export class ElectronDatabaseService { export class ElectronDatabaseService {
/** Shorthand accessor for the preload-exposed CQRS API. */ /** Shorthand accessor for the preload-exposed CQRS API. */
private get api(): ElectronAPI { private get api(): ElectronAPI {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
return (window as any).electronAPI as ElectronAPI; return (window as any).electronAPI as ElectronAPI;
} }
@@ -165,22 +165,22 @@ export class ElectronDatabaseService {
} }
/** Persist attachment metadata. */ /** Persist attachment metadata. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
saveAttachment(attachment: any): Promise<void> { saveAttachment(attachment: any): Promise<void> {
return this.api.command({ type: 'save-attachment', payload: { attachment } }); return this.api.command({ type: 'save-attachment', payload: { attachment } });
} }
/** Return all attachment records for a message. */ /** Return all attachment records for a message. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
getAttachmentsForMessage(messageId: string): Promise<any[]> { getAttachmentsForMessage(messageId: string): Promise<any[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
return this.api.query<any[]>({ type: 'get-attachments-for-message', payload: { messageId } }); return this.api.query<any[]>({ type: 'get-attachments-for-message', payload: { messageId } });
} }
/** Return every persisted attachment record. */ /** Return every persisted attachment record. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
getAllAttachments(): Promise<any[]> { getAllAttachments(): Promise<any[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} }); return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} });
} }

View File

@@ -1,6 +1,14 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
interface ExternalLinkElectronApi {
openExternal?: (url: string) => Promise<boolean>;
}
type ExternalLinkWindow = Window & {
electronAPI?: ExternalLinkElectronApi;
};
/** /**
* Opens URLs in the system default browser (Electron) or a new tab (browser). * Opens URLs in the system default browser (Electron) or a new tab (browser).
* *
@@ -17,7 +25,7 @@ export class ExternalLinkService {
return; return;
if (this.platform.isElectron) { if (this.platform.isElectron) {
(window as any).electronAPI?.openExternal(url); (window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
} else { } else {
window.open(url, '_blank', 'noopener,noreferrer'); window.open(url, '_blank', 'noopener,noreferrer');
} }

View File

@@ -1,5 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
type ElectronPlatformWindow = Window & {
electronAPI?: unknown;
};
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlatformService { export class PlatformService {
readonly isElectron: boolean; readonly isElectron: boolean;
@@ -7,7 +11,7 @@ export class PlatformService {
constructor() { constructor() {
this.isElectron = this.isElectron =
typeof window !== 'undefined' && !!(window as any).electronAPI; typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI;
this.isBrowser = !this.isElectron; this.isBrowser = !this.isElectron;
} }

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, */
import { import {
Injectable, Injectable,
signal, signal,
@@ -7,6 +7,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Room } from '../models';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
/** /**
@@ -122,19 +123,21 @@ export class VoiceSessionService {
if (!session) if (!session)
return; return;
const room: Room = {
id: session.serverId,
name: session.serverName,
description: session.serverDescription,
hostId: '',
isPrivate: false,
createdAt: 0,
userCount: 0,
maxUsers: 50,
icon: session.serverIcon
};
this.store.dispatch( this.store.dispatch(
RoomsActions.viewServer({ RoomsActions.viewServer({
room: { room
id: session.serverId,
name: session.serverName,
description: session.serverDescription,
hostId: '',
isPrivate: false,
createdAt: 0,
userCount: 0,
maxUsers: 50,
icon: session.serverIcon
} as any
}) })
); );

View File

@@ -11,7 +11,7 @@
* This file wires them together and exposes a public API that is * This file wires them together and exposes a public API that is
* identical to the old monolithic service so consumers don't change. * identical to the old monolithic service so consumers don't change.
*/ */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars */
import { import {
Injectable, Injectable,
signal, signal,
@@ -54,6 +54,27 @@ import {
P2P_TYPE_SCREEN_STATE P2P_TYPE_SCREEN_STATE
} from './webrtc'; } from './webrtc';
interface SignalingUserSummary {
oderId: string;
displayName: string;
}
interface IncomingSignalingPayload {
sdp?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
}
type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> & {
type: string;
payload?: IncomingSignalingPayload;
oderId?: string;
serverTime?: number;
serverId?: string;
users?: SignalingUserSummary[];
displayName?: string;
fromUserId?: string;
};
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@@ -120,7 +141,7 @@ export class WebRTCService implements OnDestroy {
/** Per-peer latency map (ms). Read via `peerLatencies()`. */ /** Per-peer latency map (ms). Read via `peerLatencies()`. */
readonly peerLatencies = computed(() => this._peerLatencies()); readonly peerLatencies = computed(() => this._peerLatencies());
private readonly signalingMessage$ = new Subject<SignalingMessage>(); private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable(); readonly onSignalingMessage = this.signalingMessage$.asObservable();
// Delegates to managers // Delegates to managers
@@ -175,7 +196,7 @@ export class WebRTCService implements OnDestroy {
getActivePeers: (): Map<string, import('./webrtc').PeerData> => getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
this.peerManager.activePeerConnections, this.peerManager.activePeerConnections,
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId), renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event), broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event),
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(), getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
getIdentifyDisplayName: (): string => getIdentifyDisplayName: (): string =>
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
@@ -254,110 +275,145 @@ export class WebRTCService implements OnDestroy {
}); });
} }
private handleSignalingMessage(message: any): void { private handleSignalingMessage(message: IncomingSignalingMessage): void {
this.signalingMessage$.next(message); this.signalingMessage$.next(message);
this.logger.info('Signaling message', { type: message.type }); this.logger.info('Signaling message', { type: message.type });
switch (message.type) { switch (message.type) {
case SIGNALING_TYPE_CONNECTED: case SIGNALING_TYPE_CONNECTED:
this.logger.info('Server connected', { oderId: message.oderId }); this.handleConnectedSignalingMessage(message);
return;
if (typeof message.serverTime === 'number') { case SIGNALING_TYPE_SERVER_USERS:
this.timeSync.setFromServerTime(message.serverTime); this.handleServerUsersSignalingMessage(message);
} return;
break;
case SIGNALING_TYPE_SERVER_USERS: {
this.logger.info('Server users', {
count: Array.isArray(message.users) ? message.users.length : 0,
serverId: message.serverId
});
if (message.users && Array.isArray(message.users)) {
message.users.forEach((user: { oderId: string; displayName: string }) => {
if (!user.oderId)
return;
const existing = this.peerManager.activePeerConnections.get(user.oderId);
const healthy = this.isPeerHealthy(existing);
if (existing && !healthy) {
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
this.peerManager.removePeer(user.oderId);
}
if (!healthy) {
this.logger.info('Create peer connection to existing user', {
oderId: user.oderId,
serverId: message.serverId
});
this.peerManager.createPeerConnection(user.oderId, true);
this.peerManager.createAndSendOffer(user.oderId);
if (message.serverId) {
this.peerServerMap.set(user.oderId, message.serverId);
}
}
});
}
break;
}
case SIGNALING_TYPE_USER_JOINED: case SIGNALING_TYPE_USER_JOINED:
this.logger.info('User joined', { this.handleUserJoinedSignalingMessage(message);
displayName: message.displayName, return;
oderId: message.oderId
});
break;
case SIGNALING_TYPE_USER_LEFT: case SIGNALING_TYPE_USER_LEFT:
this.logger.info('User left', { this.handleUserLeftSignalingMessage(message);
displayName: message.displayName, return;
oderId: message.oderId,
serverId: message.serverId
});
if (message.oderId) {
this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId);
}
break;
case SIGNALING_TYPE_OFFER: case SIGNALING_TYPE_OFFER:
if (message.fromUserId && message.payload?.sdp) { this.handleOfferSignalingMessage(message);
// Track inbound peer as belonging to our effective server return;
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
if (offerEffectiveServer && !this.peerServerMap.has(message.fromUserId)) {
this.peerServerMap.set(message.fromUserId, offerEffectiveServer);
}
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
}
break;
case SIGNALING_TYPE_ANSWER: case SIGNALING_TYPE_ANSWER:
if (message.fromUserId && message.payload?.sdp) { this.handleAnswerSignalingMessage(message);
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp); return;
}
break;
case SIGNALING_TYPE_ICE_CANDIDATE: case SIGNALING_TYPE_ICE_CANDIDATE:
if (message.fromUserId && message.payload?.candidate) { this.handleIceCandidateSignalingMessage(message);
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate); return;
}
break; default:
return;
} }
} }
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('Server connected', { oderId: message.oderId });
if (typeof message.serverTime === 'number') {
this.timeSync.setFromServerTime(message.serverTime);
}
}
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
const users = Array.isArray(message.users) ? message.users : [];
this.logger.info('Server users', {
count: users.length,
serverId: message.serverId
});
for (const user of users) {
if (!user.oderId)
continue;
const existing = this.peerManager.activePeerConnections.get(user.oderId);
const healthy = this.isPeerHealthy(existing);
if (existing && !healthy) {
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
this.peerManager.removePeer(user.oderId);
}
if (healthy)
continue;
this.logger.info('Create peer connection to existing user', {
oderId: user.oderId,
serverId: message.serverId
});
this.peerManager.createPeerConnection(user.oderId, true);
this.peerManager.createAndSendOffer(user.oderId);
if (message.serverId) {
this.peerServerMap.set(user.oderId, message.serverId);
}
}
}
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('User joined', {
displayName: message.displayName,
oderId: message.oderId
});
}
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('User left', {
displayName: message.displayName,
oderId: message.oderId,
serverId: message.serverId
});
if (message.oderId) {
this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId);
}
}
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
this.peerServerMap.set(fromUserId, offerEffectiveServer);
}
this.peerManager.handleOffer(fromUserId, sdp);
}
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
this.peerManager.handleAnswer(fromUserId, sdp);
}
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const candidate = message.payload?.candidate;
if (!fromUserId || !candidate)
return;
this.peerManager.handleIceCandidate(fromUserId, candidate);
}
/** /**
* Close all peer connections that were discovered from a server * Close all peer connections that were discovered from a server
* other than `serverId`. Also removes their entries from * other than `serverId`. Also removes their entries from
@@ -395,9 +451,7 @@ export class WebRTCService implements OnDestroy {
}; };
} }
// ═══════════════════════════════════════════════════════════════════
// PUBLIC API - matches the old monolithic service's interface // PUBLIC API - matches the old monolithic service's interface
// ═══════════════════════════════════════════════════════════════════
/** /**
* Connect to a signaling server via WebSocket. * Connect to a signaling server via WebSocket.

View File

@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, id-length */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars,, id-length */
/** /**
* Manages local voice media: getUserMedia, mute, deafen, * Manages local voice media: getUserMedia, mute, deafen,
* attaching/detaching audio tracks to peer connections, bitrate tuning, * attaching/detaching audio tracks to peer connections, bitrate tuning,
* and optional RNNoise-based noise reduction. * and optional RNNoise-based noise reduction.
*/ */
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ChatEvent } from '../../models';
import { WebRTCLogger } from './webrtc-logger'; import { WebRTCLogger } from './webrtc-logger';
import { PeerData } from './webrtc.types'; import { PeerData } from './webrtc.types';
import { NoiseReductionManager } from './noise-reduction.manager'; import { NoiseReductionManager } from './noise-reduction.manager';
@@ -35,7 +36,7 @@ export interface MediaManagerCallbacks {
/** Trigger SDP renegotiation for a specific peer. */ /** Trigger SDP renegotiation for a specific peer. */
renegotiate(peerId: string): Promise<void>; renegotiate(peerId: string): Promise<void>;
/** Broadcast a message to all peers. */ /** Broadcast a message to all peers. */
broadcastMessage(event: any): void; broadcastMessage(event: ChatEvent): void;
/** Get identify credentials (for broadcasting). */ /** Get identify credentials (for broadcasting). */
getIdentifyOderId(): string; getIdentifyOderId(): string;
getIdentifyDisplayName(): string; getIdentifyDisplayName(): string;
@@ -410,7 +411,7 @@ export class MediaManager {
try { try {
params = sender.getParameters(); params = sender.getParameters();
} catch (error) { } catch (error) {
this.logger.warn('getParameters failed; skipping bitrate apply', error as any); this.logger.warn('getParameters failed; skipping bitrate apply', error);
return; return;
} }
@@ -421,7 +422,7 @@ export class MediaManager {
await sender.setParameters(params); await sender.setParameters(params);
this.logger.info('Applied audio bitrate', { targetBps }); this.logger.info('Applied audio bitrate', { targetBps });
} catch (error) { } catch (error) {
this.logger.warn('Failed to set audio bitrate', error as any); this.logger.warn('Failed to set audio bitrate', error);
} }
}); });
} }
@@ -632,12 +633,12 @@ export class MediaManager {
this.inputGainSourceNode?.disconnect(); this.inputGainSourceNode?.disconnect();
this.inputGainNode?.disconnect(); this.inputGainNode?.disconnect();
} catch (error) { } catch (error) {
this.logger.warn('Input gain nodes were already disconnected during teardown', error as any); this.logger.warn('Input gain nodes were already disconnected during teardown', error);
} }
if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') { if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') {
this.inputGainCtx.close().catch((error) => { this.inputGainCtx.close().catch((error) => {
this.logger.warn('Failed to close input gain audio context', error as any); this.logger.warn('Failed to close input gain audio context', error);
}); });
} }

View File

@@ -1,7 +1,4 @@
import { import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO } from '../../webrtc.constants';
TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO
} from '../../webrtc.constants';
import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service'; import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service';
import { PeerConnectionManagerContext } from '../shared'; import { PeerConnectionManagerContext } from '../shared';

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */ /* eslint-disable, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */
/** /**
* Manages screen sharing: getDisplayMedia / Electron desktop capturer, * Manages screen sharing: getDisplayMedia / Electron desktop capturer,
* system-audio capture, and attaching screen tracks to peers. * system-audio capture, and attaching screen tracks to peers.
@@ -71,6 +71,49 @@ interface LinuxScreenShareMonitorAudioPipeline {
unsubscribeEnded: () => void; unsubscribeEnded: () => void;
} }
interface DesktopSource {
id: string;
name: string;
thumbnail: string;
}
interface ScreenShareElectronApi {
getSources?: () => Promise<DesktopSource[]>;
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
}
type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
maxWidth: number;
maxHeight: number;
maxFrameRate: number;
};
};
type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
};
};
interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
video: ElectronDesktopVideoConstraint;
audio?: false | ElectronDesktopAudioConstraint;
}
type ScreenShareWindow = Window & {
electronAPI?: ScreenShareElectronApi;
};
export class ScreenShareManager { export class ScreenShareManager {
/** The active screen-capture stream. */ /** The active screen-capture stream. */
private activeScreenStream: MediaStream | null = null; private activeScreenStream: MediaStream | null = null;
@@ -105,10 +148,10 @@ export class ScreenShareManager {
* Replace the callback set at runtime. * Replace the callback set at runtime.
* Needed because of circular initialisation between managers. * Needed because of circular initialisation between managers.
* *
* @param cb - The new callback interface to wire into this manager. * @param nextCallbacks - The new callback interface to wire into this manager.
*/ */
setCallbacks(cb: ScreenShareCallbacks): void { setCallbacks(nextCallbacks: ScreenShareCallbacks): void {
this.callbacks = cb; this.callbacks = nextCallbacks;
} }
/** Returns the current screen-capture stream, or `null` if inactive. */ /** Returns the current screen-capture stream, or `null` if inactive. */
@@ -151,7 +194,7 @@ export class ScreenShareManager {
try { try {
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset); this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
} catch (error) { } catch (error) {
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error as any); this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
} }
} }
@@ -165,15 +208,15 @@ export class ScreenShareManager {
this.activeScreenStream = null; this.activeScreenStream = null;
} }
} catch (error) { } catch (error) {
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error as any); this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
} }
} }
if (!this.activeScreenStream && typeof window !== 'undefined' && (window as any).electronAPI?.getSources) { if (!this.activeScreenStream && this.getElectronApi()?.getSources) {
try { try {
this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset); this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset);
} catch (error) { } catch (error) {
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error as any); this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
} }
} }
@@ -189,7 +232,13 @@ export class ScreenShareManager {
this.isScreenActive = true; this.isScreenActive = true;
this.callbacks.broadcastCurrentStates(); this.callbacks.broadcastCurrentStates();
const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0]; const activeScreenStream = this.activeScreenStream;
if (!activeScreenStream) {
throw new Error('Screen sharing did not produce an active stream.');
}
const screenVideoTrack = activeScreenStream.getVideoTracks()[0];
if (screenVideoTrack) { if (screenVideoTrack) {
screenVideoTrack.onended = () => { screenVideoTrack.onended = () => {
@@ -198,7 +247,7 @@ export class ScreenShareManager {
}; };
} }
return this.activeScreenStream!; return activeScreenStream;
} catch (error) { } catch (error) {
this.logger.error('Failed to start screen share', error); this.logger.error('Failed to start screen share', error);
throw error; throw error;
@@ -287,6 +336,63 @@ export class ScreenShareManager {
this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset); this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset);
} }
/** Clean up all resources. */
destroy(): void {
this.stopScreenShare();
}
private getElectronApi(): ScreenShareElectronApi | null {
return typeof window !== 'undefined'
? (window as ScreenShareWindow).electronAPI ?? null
: null;
}
private getRequiredLinuxElectronApi(): Required<Pick<
ScreenShareElectronApi,
| 'prepareLinuxScreenShareAudioRouting'
| 'activateLinuxScreenShareAudioRouting'
| 'deactivateLinuxScreenShareAudioRouting'
| 'startLinuxScreenShareMonitorCapture'
| 'stopLinuxScreenShareMonitorCapture'
| 'onLinuxScreenShareMonitorAudioChunk'
| 'onLinuxScreenShareMonitorAudioEnded'
>> {
const electronApi = this.getElectronApi();
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|| !electronApi.activateLinuxScreenShareAudioRouting
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|| !electronApi.startLinuxScreenShareMonitorCapture
|| !electronApi.stopLinuxScreenShareMonitorCapture
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
throw new Error('Linux Electron audio routing is unavailable.');
}
return {
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
};
}
private assertLinuxAudioRoutingReady(
routingInfo: LinuxScreenShareAudioRoutingInfo,
unavailableReason: string
): void {
if (!routingInfo.available) {
throw new Error(routingInfo.reason || unavailableReason);
}
if (!routingInfo.monitorCaptureSupported) {
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
}
}
/** /**
* Create a dedicated stream for system audio captured alongside the screen. * Create a dedicated stream for system audio captured alongside the screen.
* *
@@ -450,15 +556,26 @@ export class ScreenShareManager {
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.'); throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
} }
return await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints); return await navigator.mediaDevices.getDisplayMedia(displayConstraints);
} }
private async startWithElectronDesktopCapturer( private async startWithElectronDesktopCapturer(
options: ScreenShareStartOptions, options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset preset: ScreenShareQualityPreset
): Promise<MediaStream> { ): Promise<MediaStream> {
const sources = await (window as any).electronAPI.getSources(); const electronApi = this.getElectronApi();
const screenSource = sources.find((source: any) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
if (!electronApi?.getSources) {
throw new Error('Electron desktop capture is unavailable.');
}
const sources = await electronApi.getSources();
const screenSource = sources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) ?? sources[0];
if (!screenSource) {
throw new Error('No desktop capture sources were available.');
}
const electronConstraints = this.buildElectronDesktopConstraints(screenSource.id, options, preset); const electronConstraints = this.buildElectronDesktopConstraints(screenSource.id, options, preset);
this.logger.info('desktopCapturer constraints', electronConstraints); this.logger.info('desktopCapturer constraints', electronConstraints);
@@ -475,7 +592,7 @@ export class ScreenShareManager {
return false; return false;
} }
const electronApi = (window as any).electronAPI; const electronApi = this.getElectronApi();
const platformHint = `${navigator.userAgent} ${navigator.platform}`; const platformHint = `${navigator.userAgent} ${navigator.platform}`;
return !!electronApi?.prepareLinuxScreenShareAudioRouting return !!electronApi?.prepareLinuxScreenShareAudioRouting
@@ -492,28 +609,20 @@ export class ScreenShareManager {
options: ScreenShareStartOptions, options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset preset: ScreenShareQualityPreset
): Promise<MediaStream> { ): Promise<MediaStream> {
const electronApi = (window as any).electronAPI; const electronApi = this.getRequiredLinuxElectronApi();
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting() as LinuxScreenShareAudioRoutingInfo; const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
if (!routingInfo?.available) { this.assertLinuxAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
throw new Error(routingInfo?.reason || 'Linux Electron audio routing is unavailable.');
}
if (!routingInfo.monitorCaptureSupported) {
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
}
let desktopStream: MediaStream | null = null; let desktopStream: MediaStream | null = null;
try { try {
const activation = await electronApi.activateLinuxScreenShareAudioRouting() as LinuxScreenShareAudioRoutingInfo; const activation = await electronApi.activateLinuxScreenShareAudioRouting();
if (!activation?.available || !activation.active) { this.assertLinuxAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
throw new Error(activation?.reason || 'Failed to activate Linux Electron audio routing.');
}
if (!activation.monitorCaptureSupported) { if (!activation.active) {
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.'); throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
} }
desktopStream = await this.startWithElectronDesktopCapturer({ desktopStream = await this.startWithElectronDesktopCapturer({
@@ -547,7 +656,7 @@ export class ScreenShareManager {
this.linuxAudioRoutingResetPromise = this.resetLinuxElectronAudioRouting() this.linuxAudioRoutingResetPromise = this.resetLinuxElectronAudioRouting()
.catch((error) => { .catch((error) => {
this.logger.warn('Failed to reset Linux Electron audio routing', error as any); this.logger.warn('Failed to reset Linux Electron audio routing', error);
}) })
.finally(() => { .finally(() => {
this.linuxAudioRoutingResetPromise = null; this.linuxAudioRoutingResetPromise = null;
@@ -563,7 +672,7 @@ export class ScreenShareManager {
} }
private async resetLinuxElectronAudioRouting(): Promise<void> { private async resetLinuxElectronAudioRouting(): Promise<void> {
const electronApi = typeof window !== 'undefined' ? (window as any).electronAPI : null; const electronApi = this.getElectronApi();
const captureId = this.linuxMonitorAudioPipeline?.captureId; const captureId = this.linuxMonitorAudioPipeline?.captureId;
this.linuxElectronAudioRoutingActive = false; this.linuxElectronAudioRoutingActive = false;
@@ -575,7 +684,7 @@ export class ScreenShareManager {
await electronApi.stopLinuxScreenShareMonitorCapture(captureId); await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
} }
} catch (error) { } catch (error) {
this.logger.warn('Failed to stop Linux screen-share monitor capture', error as any); this.logger.warn('Failed to stop Linux screen-share monitor capture', error);
} }
try { try {
@@ -583,7 +692,7 @@ export class ScreenShareManager {
await electronApi.deactivateLinuxScreenShareAudioRouting(); await electronApi.deactivateLinuxScreenShareAudioRouting();
} }
} catch (error) { } catch (error) {
this.logger.warn('Failed to deactivate Linux Electron audio routing', error as any); this.logger.warn('Failed to deactivate Linux Electron audio routing', error);
} }
} }
@@ -591,7 +700,7 @@ export class ScreenShareManager {
audioTrack: MediaStreamTrack; audioTrack: MediaStreamTrack;
captureInfo: LinuxScreenShareMonitorCaptureInfo; captureInfo: LinuxScreenShareMonitorCaptureInfo;
}> { }> {
const electronApi = (window as any).electronAPI; const electronApi = this.getElectronApi();
if (!electronApi?.startLinuxScreenShareMonitorCapture if (!electronApi?.startLinuxScreenShareMonitorCapture
|| !electronApi?.stopLinuxScreenShareMonitorCapture || !electronApi?.stopLinuxScreenShareMonitorCapture
@@ -626,7 +735,7 @@ export class ScreenShareManager {
return; return;
} }
this.logger.warn('Linux screen-share monitor capture ended', payload as any); this.logger.warn('Linux screen-share monitor capture ended', payload);
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === payload.captureId) { if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === payload.captureId) {
this.stopScreenShare(); this.stopScreenShare();
@@ -664,17 +773,19 @@ export class ScreenShareManager {
}; };
this.linuxMonitorAudioPipeline = pipeline; this.linuxMonitorAudioPipeline = pipeline;
const activeCaptureId = captureInfo.captureId;
audioTrack.addEventListener('ended', () => { audioTrack.addEventListener('ended', () => {
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === captureInfo?.captureId) { if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === activeCaptureId) {
this.stopScreenShare(); this.stopScreenShare();
} }
}, { once: true }); }, { once: true });
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || []; const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
const activePipeline = pipeline;
queuedChunks.forEach((chunk) => { queuedChunks.forEach((chunk) => {
this.handleLinuxScreenShareMonitorAudioChunk(pipeline!, chunk); this.handleLinuxScreenShareMonitorAudioChunk(activePipeline, chunk);
}); });
queuedChunksByCaptureId.delete(captureInfo.captureId); queuedChunksByCaptureId.delete(captureInfo.captureId);
@@ -699,7 +810,7 @@ export class ScreenShareManager {
try { try {
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId); await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
} catch (stopError) { } catch (stopError) {
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError as any); this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError);
} }
throw error; throw error;
@@ -724,7 +835,7 @@ export class ScreenShareManager {
pipeline.pendingBytes = new Uint8Array(0); pipeline.pendingBytes = new Uint8Array(0);
void pipeline.audioContext.close().catch((error) => { void pipeline.audioContext.close().catch((error) => {
this.logger.warn('Failed to close Linux screen-share monitor audio context', error as any); this.logger.warn('Failed to close Linux screen-share monitor audio context', error);
}); });
} }
@@ -736,7 +847,7 @@ export class ScreenShareManager {
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', { this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
bitsPerSample: pipeline.bitsPerSample, bitsPerSample: pipeline.bitsPerSample,
captureId: pipeline.captureId captureId: pipeline.captureId
} as any); });
return; return;
} }
@@ -762,7 +873,7 @@ export class ScreenShareManager {
if (pipeline.audioContext.state !== 'running') { if (pipeline.audioContext.state !== 'running') {
void pipeline.audioContext.resume().catch((error) => { void pipeline.audioContext.resume().catch((error) => {
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error as any); this.logger.warn('Failed to resume Linux screen-share monitor audio context', error);
}); });
} }
@@ -868,8 +979,8 @@ export class ScreenShareManager {
sourceId: string, sourceId: string,
options: ScreenShareStartOptions, options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset preset: ScreenShareQualityPreset
): MediaStreamConstraints { ): ElectronDesktopMediaStreamConstraints {
const electronConstraints: any = { const electronConstraints: ElectronDesktopMediaStreamConstraints = {
video: { video: {
mandatory: { mandatory: {
chromeMediaSource: 'desktop', chromeMediaSource: 'desktop',
@@ -915,7 +1026,7 @@ export class ScreenShareManager {
height: { ideal: preset.height, max: preset.height }, height: { ideal: preset.height, max: preset.height },
frameRate: { ideal: preset.frameRate, max: preset.frameRate } frameRate: { ideal: preset.frameRate, max: preset.frameRate }
}).catch((error) => { }).catch((error) => {
this.logger.warn('Failed to re-apply screen video constraints', error as any); this.logger.warn('Failed to re-apply screen video constraints', error);
}); });
} }
} }
@@ -947,12 +1058,7 @@ export class ScreenShareManager {
maxFramerate: preset.frameRate maxFramerate: preset.frameRate
}); });
} catch (error) { } catch (error) {
this.logger.warn('Failed to apply screen-share sender parameters', error as any, { peerId }); this.logger.warn('Failed to apply screen-share sender parameters', error, { peerId });
} }
} }
/** Clean up all resources. */
destroy(): void {
this.stopScreenShare();
}
} }

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, max-statements-per-line */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion,, max-statements-per-line */
/** /**
* 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.
@@ -18,6 +18,17 @@ import {
SIGNALING_TYPE_VIEW_SERVER SIGNALING_TYPE_VIEW_SERVER
} from './webrtc.constants'; } from './webrtc.constants';
interface ParsedSignalingPayload {
sdp?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
}
type ParsedSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> &
Record<string, unknown> & {
type: string;
payload?: ParsedSignalingPayload;
};
export class SignalingManager { export class SignalingManager {
private signalingWebSocket: WebSocket | null = null; private signalingWebSocket: WebSocket | null = null;
private lastSignalingUrl: string | null = null; private lastSignalingUrl: string | null = null;
@@ -29,7 +40,7 @@ export class SignalingManager {
readonly heartbeatTick$ = new Subject<void>(); readonly heartbeatTick$ = new Subject<void>();
/** Fires whenever a raw signaling message arrives from the server. */ /** Fires whenever a raw signaling message arrives from the server. */
readonly messageReceived$ = new Subject<any>(); readonly messageReceived$ = new Subject<ParsedSignalingMessage>();
/** Fires when connection status changes (true = open, false = closed/error). */ /** Fires when connection status changes (true = open, false = closed/error). */
readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>(); readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>();
@@ -73,7 +84,7 @@ export class SignalingManager {
const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null; const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null;
try { try {
const message = JSON.parse(rawPayload) as SignalingMessage & Record<string, unknown>; const message = JSON.parse(rawPayload) as ParsedSignalingMessage;
const payloadPreview = this.buildPayloadPreview(message); const payloadPreview = this.buildPayloadPreview(message);
recordDebugNetworkSignalingPayload(message, 'inbound'); recordDebugNetworkSignalingPayload(message, 'inbound');

View File

@@ -4,6 +4,7 @@
* All log lines are prefixed with `[WebRTC]`. * All log lines are prefixed with `[WebRTC]`.
*/ */
export interface WebRTCTrafficDetails { export interface WebRTCTrafficDetails {
[key: string]: unknown;
bytes?: number; bytes?: number;
bufferedAmount?: number; bufferedAmount?: number;
channelLabel?: string; channelLabel?: string;
@@ -15,21 +16,14 @@ export interface WebRTCTrafficDetails {
targetPeerId?: string; targetPeerId?: string;
type?: string; type?: string;
url?: string | null; url?: string | null;
[key: string]: unknown;
} }
export class WebRTCLogger { export class WebRTCLogger {
constructor(private readonly isEnabled: boolean | (() => boolean) = true) {} constructor(private readonly isEnabled: boolean | (() => boolean) = true) {}
private get debugEnabled(): boolean {
return typeof this.isEnabled === 'function'
? this.isEnabled()
: this.isEnabled;
}
/** Informational log (only when debug is enabled). */ /** Informational log (only when debug is enabled). */
info(prefix: string, ...args: unknown[]): void { info(prefix: string, ...args: unknown[]): void {
if (!this.debugEnabled) if (!this.isDebugEnabled())
return; return;
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
@@ -37,7 +31,7 @@ export class WebRTCLogger {
/** Warning log (only when debug is enabled). */ /** Warning log (only when debug is enabled). */
warn(prefix: string, ...args: unknown[]): void { warn(prefix: string, ...args: unknown[]): void {
if (!this.debugEnabled) if (!this.isDebugEnabled())
return; return;
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
@@ -50,10 +44,11 @@ export class WebRTCLogger {
/** Error log (always emitted regardless of debug flag). */ /** Error log (always emitted regardless of debug flag). */
error(prefix: string, err: unknown, extra?: Record<string, unknown>): void { error(prefix: string, err: unknown, extra?: Record<string, unknown>): void {
const errorDetails = this.extractErrorDetails(err);
const payload = { const payload = {
name: (err as any)?.name, name: errorDetails.name,
message: (err as any)?.message, message: errorDetails.message,
stack: (err as any)?.stack, stack: errorDetails.stack,
...extra ...extra
}; };
@@ -93,7 +88,7 @@ export class WebRTCLogger {
const videoTracks = stream.getVideoTracks(); const videoTracks = stream.getVideoTracks();
this.info(`Stream ready: ${label}`, { this.info(`Stream ready: ${label}`, {
id: (stream as any).id, id: stream.id,
audioTrackCount: audioTracks.length, audioTrackCount: audioTracks.length,
videoTrackCount: videoTracks.length, videoTrackCount: videoTracks.length,
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id,
@@ -103,4 +98,32 @@ export class WebRTCLogger {
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`)); audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`)); videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
} }
private isDebugEnabled(): boolean {
return typeof this.isEnabled === 'function'
? this.isEnabled()
: this.isEnabled;
}
private extractErrorDetails(err: unknown): {
name?: unknown;
message?: unknown;
stack?: unknown;
} {
if (typeof err !== 'object' || err === null) {
return {};
}
const candidate = err as {
name?: unknown;
message?: unknown;
stack?: unknown;
};
return {
name: candidate.name,
message: candidate.message,
stack: candidate.stack
};
}
} }

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
@@ -33,10 +33,7 @@ import {
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../core/services/attachment.service'; } from '../../../../../core/services/attachment.service';
import { KlipyService } from '../../../../../core/services/klipy.service'; import { KlipyService } from '../../../../../core/services/klipy.service';
import { import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models';
DELETED_MESSAGE_CONTENT,
Message
} from '../../../../../core/models';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
@@ -81,7 +78,7 @@ const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified() const REMARK_PROCESSOR = unified()
.use(remarkParse) .use(remarkParse)
.use(remarkGfm) .use(remarkGfm)
.use(remarkBreaks) as any; .use(remarkBreaks);
interface ChatMessageAttachmentViewModel extends Attachment { interface ChatMessageAttachmentViewModel extends Attachment {
isAudio: boolean; isAudio: boolean;
@@ -133,7 +130,7 @@ export class ChatMessageItemComponent {
readonly repliedMessage = input<Message | undefined>(); readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null); readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false); readonly isAdmin = input(false);
readonly remarkProcessor: any = REMARK_PROCESSOR; readonly remarkProcessor = REMARK_PROCESSOR;
readonly replyRequested = output<ChatMessageReplyEvent>(); readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>(); readonly deleteRequested = output<ChatMessageDeleteEvent>();

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
import { import {
Component, Component,
inject, inject,
@@ -19,6 +19,12 @@ const TYPING_TTL = 3_000;
const PURGE_INTERVAL = 1_000; const PURGE_INTERVAL = 1_000;
const MAX_SHOWN = 4; const MAX_SHOWN = 4;
interface TypingSignalingMessage {
type: string;
displayName: string;
oderId: string;
}
@Component({ @Component({
selector: 'app-typing-indicator', selector: 'app-typing-indicator',
standalone: true, standalone: true,
@@ -38,12 +44,16 @@ export class TypingIndicatorComponent {
const webrtc = inject(WebRTCService); const webrtc = inject(WebRTCService);
const destroyRef = inject(DestroyRef); const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe( const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId), filter((msg): msg is TypingSignalingMessage =>
tap((msg: any) => { msg?.type === 'user_typing' &&
typeof msg.displayName === 'string' &&
typeof msg.oderId === 'string'
),
tap((msg) => {
const now = Date.now(); const now = Date.now();
this.typingMap.set(String(msg.oderId), { this.typingMap.set(msg.oderId, {
name: String(msg.displayName), name: msg.displayName,
expiresAt: now + TYPING_TTL expiresAt: now + TYPING_TTL
}); });
}) })

View File

@@ -29,10 +29,7 @@ import {
selectVoiceChannels selectVoiceChannels
} from '../../../store/rooms/rooms.selectors'; } from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
selectCurrentUser,
selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
@Component({ @Component({

View File

@@ -549,10 +549,13 @@ export class RoomsSidePanelComponent {
return false; return false;
} }
const peerKeys = [user?.oderId, user?.id, userId].filter( const peerKeys = [
user?.oderId,
user?.id,
userId
].filter(
(candidate): candidate is string => !!candidate (candidate): candidate is string => !!candidate
); );
const stream = peerKeys const stream = peerKeys
.map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey)) .map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey))
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null; .find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;

View File

@@ -12,10 +12,7 @@ import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide'; import { lucidePlus } from '@ng-icons/lucide';
import { import { Room, User } from '../../core/models/index';
Room,
User
} from '../../core/models/index';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service'; import { VoiceSessionService } from '../../core/services/voice-session.service';

View File

@@ -8,10 +8,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import { Actions, ofType } from '@ngrx/effects';
Actions,
ofType
} from '@ngrx/effects';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide'; import { lucideX } from '@ng-icons/lucide';

View File

@@ -14,7 +14,6 @@ import { lucideUserX, lucideBan } from '@ng-icons/lucide';
import { import {
Room, Room,
RoomMember, RoomMember,
User,
UserRole UserRole
} from '../../../../core/models/index'; } from '../../../../core/models/index';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';

View File

@@ -26,10 +26,7 @@ import {
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { import { Room, UserRole } from '../../../core/models/index';
Room,
UserRole
} from '../../../core/models/index';
import { findRoomMember } from '../../../store/rooms/room-members.helpers'; import { findRoomMember } from '../../../store/rooms/room-members.helpers';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
@@ -191,7 +188,6 @@ export class SettingsModalComponent {
const targetId = this.modal.targetServerId(); const targetId = this.modal.targetServerId();
const currentRoomId = this.currentRoom()?.id ?? null; const currentRoomId = this.currentRoom()?.id ?? null;
const selectedId = this.selectedServerId(); const selectedId = this.selectedServerId();
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId); const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
if (!hasSelected) { if (!hasSelected) {

View File

@@ -20,14 +20,8 @@ import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service'; import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/services/platform.service'; import { PlatformService } from '../../../../core/services/platform.service';
import { import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../core/services/voice-settings.storage';
loadVoiceSettingsFromStorage, import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../core/services/webrtc';
saveVoiceSettingsToStorage
} from '../../../../core/services/voice-settings.storage';
import {
SCREEN_SHARE_QUALITY_OPTIONS,
ScreenShareQuality
} from '../../../../core/services/webrtc';
interface AudioDevice { interface AudioDevice {
deviceId: string; deviceId: string;
@@ -46,6 +40,10 @@ interface DesktopSettingsElectronApi {
relaunchApp?: () => Promise<boolean>; relaunchApp?: () => Promise<boolean>;
} }
type DesktopSettingsWindow = Window & {
electronAPI?: DesktopSettingsElectronApi;
};
@Component({ @Component({
selector: 'app-voice-settings', selector: 'app-voice-settings',
standalone: true, standalone: true,
@@ -87,7 +85,9 @@ export class VoiceSettingsComponent {
askScreenShareQuality = signal(true); askScreenShareQuality = signal(true);
hardwareAcceleration = signal(true); hardwareAcceleration = signal(true);
hardwareAccelerationRestartRequired = signal(false); hardwareAccelerationRestartRequired = signal(false);
readonly selectedScreenShareQualityDescription = computed(() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? ''); readonly selectedScreenShareQualityDescription = computed(
() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? ''
);
constructor() { constructor() {
this.loadVoiceSettings(); this.loadVoiceSettings();
@@ -294,7 +294,7 @@ export class VoiceSettingsComponent {
private getElectronApi(): DesktopSettingsElectronApi | null { private getElectronApi(): DesktopSettingsElectronApi | null {
return typeof window !== 'undefined' return typeof window !== 'undefined'
? (window as any).electronAPI as DesktopSettingsElectronApi ? (window as DesktopSettingsWindow).electronAPI ?? null
: null; : null;
} }
} }

View File

@@ -26,6 +26,16 @@ import { PlatformService } from '../../core/services/platform.service';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared'; import { LeaveServerDialogComponent } from '../../shared';
interface WindowControlsAPI {
minimizeWindow?: () => void;
maximizeWindow?: () => void;
closeWindow?: () => void;
}
type ElectronWindow = Window & {
electronAPI?: WindowControlsAPI;
};
@Component({ @Component({
selector: 'app-title-bar', selector: 'app-title-bar',
standalone: true, standalone: true,
@@ -54,6 +64,10 @@ export class TitleBarComponent {
private webrtc = inject(WebRTCService); private webrtc = inject(WebRTCService);
private platform = inject(PlatformService); private platform = inject(PlatformService);
private getWindowControlsApi(): WindowControlsAPI | undefined {
return (window as ElectronWindow).electronAPI;
}
isElectron = computed(() => this.platform.isElectron); isElectron = computed(() => this.platform.isElectron);
showMenuState = computed(() => false); showMenuState = computed(() => false);
@@ -73,26 +87,23 @@ export class TitleBarComponent {
/** Minimize the Electron window. */ /** Minimize the Electron window. */
minimize() { minimize() {
const api = (window as any).electronAPI; const api = this.getWindowControlsApi();
if (api?.minimizeWindow) api?.minimizeWindow?.();
api.minimizeWindow();
} }
/** Maximize or restore the Electron window. */ /** Maximize or restore the Electron window. */
maximize() { maximize() {
const api = (window as any).electronAPI; const api = this.getWindowControlsApi();
if (api?.maximizeWindow) api?.maximizeWindow?.();
api.maximizeWindow();
} }
/** Close the Electron window. */ /** Close the Electron window. */
close() { close() {
const api = (window as any).electronAPI; const api = this.getWindowControlsApi();
if (api?.closeWindow) api?.closeWindow?.();
api.closeWindow();
} }
/** Navigate to the login page. */ /** Navigate to the login page. */

View File

@@ -23,22 +23,21 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { VoiceSessionService } from '../../../core/services/voice-session.service';
import { import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage';
loadVoiceSettingsFromStorage,
saveVoiceSettingsToStorage
} from '../../../core/services/voice-settings.storage';
import { ScreenShareQuality } from '../../../core/services/webrtc'; import { ScreenShareQuality } from '../../../core/services/webrtc';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
DebugConsoleComponent,
ScreenShareQualityDialogComponent
} from '../../../shared';
@Component({ @Component({
selector: 'app-floating-voice-controls', selector: 'app-floating-voice-controls',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, DebugConsoleComponent, ScreenShareQualityDialogComponent], imports: [
CommonModule,
NgIcon,
DebugConsoleComponent,
ScreenShareQualityDialogComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideMic, lucideMic,
@@ -283,6 +282,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
includeSystemAudio: this.includeSystemAudio(), includeSystemAudio: this.includeSystemAudio(),
quality quality
}); });
this.isScreenSharing.set(true); this.isScreenSharing.set(true);
} catch (_error) { } catch (_error) {
// Screen share request was denied or failed // Screen share request was denied or failed

View File

@@ -183,9 +183,9 @@ export class VoicePlaybackService {
return; return;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
const anyAudio = pipeline.audioElement as any; const anyAudio = pipeline.audioElement as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line
const anyCtx = pipeline.context as any; const anyCtx = pipeline.context as any;
const tasks: Promise<unknown>[] = []; const tasks: Promise<unknown>[] = [];

View File

@@ -17,17 +17,24 @@ import {
} from 'rxjs'; } from 'rxjs';
import { mergeMap } from 'rxjs/operators'; import { mergeMap } from 'rxjs/operators';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { DELETED_MESSAGE_CONTENT, Message } from '../../core/models/index'; import {
DELETED_MESSAGE_CONTENT,
type ChatEvent,
type Message,
type Room,
type User
} from '../../core/models/index';
import type { DebuggingService } from '../../core/services'; import type { DebuggingService } from '../../core/services';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { AttachmentService } from '../../core/services/attachment.service'; import { AttachmentService, type AttachmentMeta } from '../../core/services/attachment.service';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
import { import {
INVENTORY_LIMIT, INVENTORY_LIMIT,
CHUNK_SIZE, CHUNK_SIZE,
FULL_SYNC_LIMIT, FULL_SYNC_LIMIT,
type InventoryItem,
chunkArray, chunkArray,
buildInventoryItem, buildInventoryItem,
buildLocalInventoryMap, buildLocalInventoryMap,
@@ -36,19 +43,64 @@ import {
mergeIncomingMessage mergeIncomingMessage
} from './messages.helpers'; } from './messages.helpers';
type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
type IncomingMessageType =
| ChatEvent['type']
| 'chat-inventory'
| 'chat-sync-request-ids'
| 'chat-sync-batch'
| 'chat-sync-summary'
| 'chat-sync-request'
| 'chat-sync-full'
| 'file-announce'
| 'file-chunk'
| 'file-request'
| 'file-cancel'
| 'file-not-found';
interface IncomingMessageEvent extends Omit<ChatEvent, 'type'> {
type: IncomingMessageType;
items?: InventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: AttachmentMetaMap;
total?: number;
index?: number;
count?: number;
lastUpdated?: number;
file?: AnnouncedAttachment;
fileId?: string;
}
type SyncBatchEvent = IncomingMessageEvent & {
messages: Message[];
attachments?: AttachmentMetaMap;
};
function hasMessageBatch(event: IncomingMessageEvent): event is SyncBatchEvent {
return Array.isArray(event.messages);
}
function hasAttachmentMetaMap(
attachmentMap: IncomingMessageEvent['attachments']
): attachmentMap is AttachmentMetaMap {
return typeof attachmentMap === 'object' && attachmentMap !== null;
}
/** Shared context injected into each handler function. */ /** Shared context injected into each handler function. */
export interface IncomingMessageContext { export interface IncomingMessageContext {
db: DatabaseService; db: DatabaseService;
webrtc: WebRTCService; webrtc: WebRTCService;
attachments: AttachmentService; attachments: AttachmentService;
debugging: DebuggingService; debugging: DebuggingService;
currentUser: any; currentUser: User | null;
currentRoom: any; currentRoom: Room | null;
} }
/** Signature for an incoming-message handler function. */ /** Signature for an incoming-message handler function. */
type MessageHandler = ( type MessageHandler = (
event: any, event: IncomingMessageEvent,
ctx: IncomingMessageContext, ctx: IncomingMessageContext,
) => Observable<Action>; ) => Observable<Action>;
@@ -57,7 +109,7 @@ type MessageHandler = (
* our local message inventory in chunks. * our local message inventory in chunks.
*/ */
function handleInventoryRequest( function handleInventoryRequest(
event: any, event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext { db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { roomId, fromPeerId } = event; const { roomId, fromPeerId } = event;
@@ -83,13 +135,15 @@ function handleInventoryRequest(
items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts); items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts);
for (const chunk of chunkArray(items, CHUNK_SIZE)) { for (const chunk of chunkArray(items, CHUNK_SIZE)) {
webrtc.sendToPeer(fromPeerId, { const inventoryEvent: IncomingMessageEvent = {
type: 'chat-inventory', type: 'chat-inventory',
roomId, roomId,
items: chunk, items: chunk,
total: items.length, total: items.length,
index: 0 index: 0
} as any); };
webrtc.sendToPeer(fromPeerId, inventoryEvent);
} }
})() })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
@@ -100,7 +154,7 @@ function handleInventoryRequest(
* and requests any missing or stale messages. * and requests any missing or stale messages.
*/ */
function handleInventory( function handleInventory(
event: any, event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext { db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { roomId, fromPeerId, items } = event; const { roomId, fromPeerId, items } = event;
@@ -125,11 +179,13 @@ function handleInventory(
const missing = findMissingIds(items, localMap); const missing = findMissingIds(items, localMap);
for (const chunk of chunkArray(missing, CHUNK_SIZE)) { for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
webrtc.sendToPeer(fromPeerId, { const syncRequestIdsEvent: IncomingMessageEvent = {
type: 'chat-sync-request-ids', type: 'chat-sync-request-ids',
roomId, roomId,
ids: chunk ids: chunk
} as any); };
webrtc.sendToPeer(fromPeerId, syncRequestIdsEvent);
} }
})() })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
@@ -140,7 +196,7 @@ function handleInventory(
* hydrated messages along with their attachment metadata. * hydrated messages along with their attachment metadata.
*/ */
function handleSyncRequestIds( function handleSyncRequestIds(
event: any, event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext { db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { roomId, ids, fromPeerId } = event; const { roomId, ids, fromPeerId } = event;
@@ -164,14 +220,14 @@ function handleSyncRequestIds(
attachments.getAttachmentMetasForMessages(msgIds); attachments.getAttachmentMetasForMessages(msgIds);
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) { for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
const chunkAttachments: Record<string, any> = {}; const chunkAttachments: AttachmentMetaMap = {};
for (const hydratedMessage of chunk) { for (const hydratedMessage of chunk) {
if (attachmentMetas[hydratedMessage.id]) if (attachmentMetas[hydratedMessage.id])
chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id]; chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id];
} }
webrtc.sendToPeer(fromPeerId, { const syncBatchEvent: IncomingMessageEvent = {
type: 'chat-sync-batch', type: 'chat-sync-batch',
roomId: roomId || '', roomId: roomId || '',
messages: chunk, messages: chunk,
@@ -179,7 +235,9 @@ function handleSyncRequestIds(
Object.keys(chunkAttachments).length > 0 Object.keys(chunkAttachments).length > 0
? chunkAttachments ? chunkAttachments
: undefined : undefined
} as any); };
webrtc.sendToPeer(fromPeerId, syncBatchEvent);
} }
})() })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
@@ -191,13 +249,13 @@ function handleSyncRequestIds(
* missing image attachments. * missing image attachments.
*/ */
function handleSyncBatch( function handleSyncBatch(
event: any, event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext { db, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!Array.isArray(event.messages)) if (!hasMessageBatch(event))
return EMPTY; return EMPTY;
if (event.attachments && typeof event.attachments === 'object') { if (hasAttachmentMetaMap(event.attachments)) {
attachments.registerSyncedAttachments(event.attachments); attachments.registerSyncedAttachments(event.attachments);
} }
@@ -212,13 +270,13 @@ function handleSyncBatch(
/** Merges each incoming message and collects those that changed. */ /** Merges each incoming message and collects those that changed. */
async function processSyncBatch( async function processSyncBatch(
event: any, event: SyncBatchEvent,
db: DatabaseService, db: DatabaseService,
attachments: AttachmentService attachments: AttachmentService
): Promise<Message[]> { ): Promise<Message[]> {
const toUpsert: Message[] = []; const toUpsert: Message[] = [];
for (const incoming of event.messages as Message[]) { for (const incoming of event.messages) {
const { message, changed } = await mergeIncomingMessage(incoming, db); const { message, changed } = await mergeIncomingMessage(incoming, db);
if (incoming.isDeleted) { if (incoming.isDeleted) {
@@ -233,7 +291,7 @@ async function processSyncBatch(
toUpsert.push(message); toUpsert.push(message);
} }
if (event.attachments && typeof event.attachments === 'object') { if (hasAttachmentMetaMap(event.attachments)) {
requestMissingImages(event.attachments, attachments); requestMissingImages(event.attachments, attachments);
} }
@@ -242,7 +300,7 @@ async function processSyncBatch(
/** Auto-requests any unavailable image attachments from any connected peer. */ /** Auto-requests any unavailable image attachments from any connected peer. */
function requestMissingImages( function requestMissingImages(
attachmentMap: Record<string, any[]>, attachmentMap: AttachmentMetaMap,
attachments: AttachmentService attachments: AttachmentService
): void { ): void {
for (const [msgId, metas] of Object.entries(attachmentMap)) { for (const [msgId, metas] of Object.entries(attachmentMap)) {
@@ -251,7 +309,7 @@ function requestMissingImages(
continue; continue;
const atts = attachments.getForMessage(msgId); const atts = attachments.getForMessage(msgId);
const matchingAttachment = atts.find((attachment: any) => attachment.id === meta.id); const matchingAttachment = atts.find((attachment) => attachment.id === meta.id);
if ( if (
matchingAttachment && matchingAttachment &&
@@ -266,7 +324,7 @@ function requestMissingImages(
/** Saves an incoming chat message to DB and dispatches receiveMessage. */ /** Saves an incoming chat message to DB and dispatches receiveMessage. */
function handleChatMessage( function handleChatMessage(
event: any, event: IncomingMessageEvent,
{ db, debugging, currentUser }: IncomingMessageContext { db, debugging, currentUser }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const msg = event.message; const msg = event.message;
@@ -300,21 +358,25 @@ function handleChatMessage(
/** Applies a remote message edit to the local DB and store. */ /** Applies a remote message edit to the local DB and store. */
function handleMessageEdited( function handleMessageEdited(
event: any, event: IncomingMessageEvent,
{ db, debugging }: IncomingMessageContext { db, debugging }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId || !event.content) if (!event.messageId || !event.content)
return EMPTY; return EMPTY;
const editedAt = typeof event.editedAt === 'number'
? event.editedAt
: Date.now();
trackBackgroundOperation( trackBackgroundOperation(
db.updateMessage(event.messageId, { db.updateMessage(event.messageId, {
content: event.content, content: event.content,
editedAt: event.editedAt editedAt
}), }),
debugging, debugging,
'Failed to persist incoming message edit', 'Failed to persist incoming message edit',
{ {
editedAt: event.editedAt ?? null, editedAt,
fromPeerId: event.fromPeerId ?? null, fromPeerId: event.fromPeerId ?? null,
messageId: event.messageId messageId: event.messageId
} }
@@ -324,14 +386,14 @@ function handleMessageEdited(
MessagesActions.editMessageSuccess({ MessagesActions.editMessageSuccess({
messageId: event.messageId, messageId: event.messageId,
content: event.content, content: event.content,
editedAt: event.editedAt editedAt
}) })
); );
} }
/** Applies a remote message deletion to the local DB and store. */ /** Applies a remote message deletion to the local DB and store. */
function handleMessageDeleted( function handleMessageDeleted(
event: any, event: IncomingMessageEvent,
{ db, debugging, attachments }: IncomingMessageContext { db, debugging, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId) if (!event.messageId)
@@ -375,7 +437,7 @@ function handleMessageDeleted(
/** Saves an incoming reaction to DB and updates the store. */ /** Saves an incoming reaction to DB and updates the store. */
function handleReactionAdded( function handleReactionAdded(
event: any, event: IncomingMessageEvent,
{ db, debugging }: IncomingMessageContext { db, debugging }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId || !event.reaction) if (!event.messageId || !event.reaction)
@@ -398,7 +460,7 @@ function handleReactionAdded(
/** Removes a reaction from DB and updates the store. */ /** Removes a reaction from DB and updates the store. */
function handleReactionRemoved( function handleReactionRemoved(
event: any, event: IncomingMessageEvent,
{ db, debugging }: IncomingMessageContext { db, debugging }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId || !event.oderId || !event.emoji) if (!event.messageId || !event.oderId || !event.emoji)
@@ -426,7 +488,7 @@ function handleReactionRemoved(
} }
function handleFileAnnounce( function handleFileAnnounce(
event: any, event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileAnnounce(event); attachments.handleFileAnnounce(event);
@@ -434,7 +496,7 @@ function handleFileAnnounce(
} }
function handleFileChunk( function handleFileChunk(
event: any, event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileChunk(event); attachments.handleFileChunk(event);
@@ -442,7 +504,7 @@ function handleFileChunk(
} }
function handleFileRequest( function handleFileRequest(
event: any, event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileRequest(event); attachments.handleFileRequest(event);
@@ -450,7 +512,7 @@ function handleFileRequest(
} }
function handleFileCancel( function handleFileCancel(
event: any, event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileCancel(event); attachments.handleFileCancel(event);
@@ -458,7 +520,7 @@ function handleFileCancel(
} }
function handleFileNotFound( function handleFileNotFound(
event: any, event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileNotFound(event); attachments.handleFileNotFound(event);
@@ -470,7 +532,7 @@ function handleFileNotFound(
* if the peer has newer or more data. * if the peer has newer or more data.
*/ */
function handleSyncSummary( function handleSyncSummary(
event: any, event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext { db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!currentRoom) if (!currentRoom)
@@ -491,12 +553,15 @@ function handleSyncSummary(
const needsSync = const needsSync =
remoteLastUpdated > localLastUpdated || remoteLastUpdated > localLastUpdated ||
(remoteLastUpdated === localLastUpdated && remoteCount > localCount); (remoteLastUpdated === localLastUpdated && remoteCount > localCount);
const fromPeerId = event.fromPeerId;
if (!identical && needsSync && event.fromPeerId) { if (!identical && needsSync && fromPeerId) {
webrtc.sendToPeer(event.fromPeerId, { const syncRequestEvent: IncomingMessageEvent = {
type: 'chat-sync-request', type: 'chat-sync-request',
roomId: currentRoom.id roomId: currentRoom.id
} as any); };
webrtc.sendToPeer(fromPeerId, syncRequestEvent);
} }
})() })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
@@ -504,31 +569,34 @@ function handleSyncSummary(
/** Responds to a peer's full sync request by sending all local messages. */ /** Responds to a peer's full sync request by sending all local messages. */
function handleSyncRequest( function handleSyncRequest(
event: any, event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext { db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!currentRoom || !event.fromPeerId) const fromPeerId = event.fromPeerId;
if (!currentRoom || !fromPeerId)
return EMPTY; return EMPTY;
return from( return from(
(async () => { (async () => {
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0); const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
const syncFullEvent: IncomingMessageEvent = {
webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-sync-full', type: 'chat-sync-full',
roomId: currentRoom.id, roomId: currentRoom.id,
messages: all messages: all
} as any); };
webrtc.sendToPeer(fromPeerId, syncFullEvent);
})() })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
} }
/** Merges a full message dump from a peer into the local DB and store. */ /** Merges a full message dump from a peer into the local DB and store. */
function handleSyncFull( function handleSyncFull(
event: any, event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext { db, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messages || !Array.isArray(event.messages)) if (!hasMessageBatch(event))
return EMPTY; return EMPTY;
return from(processSyncBatch(event, db, attachments)).pipe( return from(processSyncBatch(event, db, attachments)).pipe(
@@ -575,7 +643,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
* Returns `EMPTY` if the event type is unknown or has no relevant handler. * Returns `EMPTY` if the event type is unknown or has no relevant handler.
*/ */
export function dispatchIncomingMessage( export function dispatchIncomingMessage(
event: any, event: IncomingMessageEvent,
ctx: IncomingMessageContext ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const handler = HANDLER_MAP[event.type]; const handler = HANDLER_MAP[event.type];

View File

@@ -89,12 +89,12 @@ export class MessagesSyncEffects {
roomId: room.id, roomId: room.id,
count, count,
lastUpdated lastUpdated
} as any); });
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: room.id roomId: room.id
} as any); });
}) })
); );
}) })
@@ -131,12 +131,12 @@ export class MessagesSyncEffects {
roomId: activeRoom.id, roomId: activeRoom.id,
count, count,
lastUpdated lastUpdated
} as any); });
this.webrtc.sendToPeer(pid, { this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: activeRoom.id roomId: activeRoom.id
} as any); });
} catch (error) { } catch (error) {
this.debugging.warn('messages', 'Failed to kick off room sync for peer', { this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
error, error,
@@ -186,7 +186,7 @@ export class MessagesSyncEffects {
this.webrtc.sendToPeer(pid, { this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: room.id roomId: room.id
} as any); });
} catch (error) { } catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', { this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error, error,

View File

@@ -383,7 +383,7 @@ export class MessagesEffects {
webrtc: this.webrtc, webrtc: this.webrtc,
attachments: this.attachments, attachments: this.attachments,
debugging: this.debugging, debugging: this.debugging,
currentUser, currentUser: currentUser ?? null,
currentRoom currentRoom
}; };

View File

@@ -134,6 +134,7 @@ export async function buildLocalInventoryMap(
rc: 0, rc: 0,
ac: 0 ac: 0
}); });
return; return;
} }

View File

@@ -4,10 +4,7 @@ import {
EntityAdapter, EntityAdapter,
createEntityAdapter createEntityAdapter
} from '@ngrx/entity'; } from '@ngrx/entity';
import { import { DELETED_MESSAGE_CONTENT, Message } from '../../core/models/index';
DELETED_MESSAGE_CONTENT,
Message
} from '../../core/models/index';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
/** State shape for the messages feature slice, extending NgRx EntityState. */ /** State shape for the messages feature slice, extending NgRx EntityState. */

View File

@@ -14,6 +14,7 @@ import {
withLatestFrom withLatestFrom
} from 'rxjs/operators'; } from 'rxjs/operators';
import { import {
ChatEvent,
Room, Room,
RoomMember, RoomMember,
User User
@@ -127,7 +128,13 @@ export class RoomMembersSyncEffects {
savedRooms, savedRooms,
currentUser currentUser
]) => { ]) => {
const signalingMessage = message as any; const signalingMessage: {
type: string;
serverId?: string;
users?: { oderId: string; displayName: string }[];
oderId?: string;
displayName?: string;
} = message;
const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined; const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
@@ -159,9 +166,14 @@ export class RoomMembersSyncEffects {
if (!signalingMessage.oderId || signalingMessage.oderId === myId) if (!signalingMessage.oderId || signalingMessage.oderId === myId)
return EMPTY; return EMPTY;
const joinedUser = {
oderId: signalingMessage.oderId,
displayName: signalingMessage.displayName
};
const members = upsertRoomMember( const members = upsertRoomMember(
room.members ?? [], room.members ?? [],
this.buildPresenceMember(room, signalingMessage) this.buildPresenceMember(room, joinedUser)
); );
const actions = this.createRoomMemberUpdateActions(room, members); const actions = this.createRoomMemberUpdateActions(room, members);
@@ -197,7 +209,7 @@ export class RoomMembersSyncEffects {
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
type: 'member-roster-request', type: 'member-roster-request',
roomId: currentRoom.id roomId: currentRoom.id
} as any); });
}) })
), ),
{ dispatch: false } { dispatch: false }
@@ -218,7 +230,7 @@ export class RoomMembersSyncEffects {
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
type: 'member-roster-request', type: 'member-roster-request',
roomId: room.id roomId: room.id
} as any); });
} catch { } catch {
/* peer may have disconnected */ /* peer may have disconnected */
} }
@@ -333,7 +345,7 @@ export class RoomMembersSyncEffects {
} }
private handleMemberRosterRequest( private handleMemberRosterRequest(
event: any, event: ChatEvent,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[], savedRooms: Room[],
currentUser: User | null currentUser: User | null
@@ -358,13 +370,13 @@ export class RoomMembersSyncEffects {
type: 'member-roster', type: 'member-roster',
roomId: room.id, roomId: room.id,
members members
} as any); });
return this.createRoomMemberUpdateActions(room, members); return this.createRoomMemberUpdateActions(room, members);
} }
private handleMemberRoster( private handleMemberRoster(
event: any, event: ChatEvent,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[], savedRooms: Room[],
currentUser: User | null currentUser: User | null
@@ -387,7 +399,7 @@ export class RoomMembersSyncEffects {
} }
private handleMemberLeave( private handleMemberLeave(
event: any, event: ChatEvent,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[] savedRooms: Room[]
): Action[] { ): Action[] {
@@ -401,10 +413,11 @@ export class RoomMembersSyncEffects {
room, room,
removeRoomMember(room.members ?? [], event.targetUserId, event.oderId) removeRoomMember(room.members ?? [], event.targetUserId, event.oderId)
); );
const departedUserId = event.oderId ?? event.targetUserId;
if (currentRoom?.id === room.id && (event.oderId || event.targetUserId)) { if (currentRoom?.id === room.id && departedUserId) {
actions.push( actions.push(
UsersActions.userLeft({ userId: event.oderId || event.targetUserId }) UsersActions.userLeft({ userId: departedUserId })
); );
} }
@@ -412,7 +425,7 @@ export class RoomMembersSyncEffects {
} }
private handleIncomingHostChange( private handleIncomingHostChange(
event: any, event: ChatEvent,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[], savedRooms: Room[],
currentUser: User | null currentUser: User | null
@@ -434,7 +447,7 @@ export class RoomMembersSyncEffects {
} }
: null, : null,
{ {
id: event.previousHostId, id: event.previousHostId ?? event.previousHostOderId ?? '',
oderId: event.previousHostOderId oderId: event.previousHostOderId
} }
); );
@@ -484,7 +497,7 @@ export class RoomMembersSyncEffects {
} }
private handleIncomingRoleChange( private handleIncomingRoleChange(
event: any, event: ChatEvent,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[] savedRooms: Room[]
): Action[] { ): Action[] {

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
/* eslint-disable id-length */ /* eslint-disable id-length */
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, complexity */ /* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { import {
@@ -34,10 +34,12 @@ import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { import {
ChatEvent,
Room, Room,
RoomSettings, RoomSettings,
RoomPermissions, RoomPermissions,
BanEntry, BanEntry,
User,
VoiceState VoiceState
} from '../../core/models/index'; } from '../../core/models/index';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
@@ -50,14 +52,16 @@ import {
/** Build a minimal User object from signaling payload. */ /** Build a minimal User object from signaling payload. */
function buildSignalingUser( function buildSignalingUser(
data: { oderId: string; displayName: string }, data: { oderId: string; displayName?: string },
extras: Record<string, unknown> = {} extras: Record<string, unknown> = {}
) { ) {
const displayName = data.displayName || 'User';
return { return {
oderId: data.oderId, oderId: data.oderId,
id: data.oderId, id: data.oderId,
username: data.displayName.toLowerCase().replace(/\s+/g, '_'), username: displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: data.displayName, displayName,
status: 'online' as const, status: 'online' as const,
isOnline: true, isOnline: true,
role: 'member' as const, role: 'member' as const,
@@ -89,6 +93,18 @@ function isWrongServer(
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId); return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
} }
interface RoomPresenceSignalingMessage {
type: string;
serverId?: string;
users?: { oderId: string; displayName: string }[];
oderId?: string;
displayName?: string;
}
type BlockedRoomAccessAction =
| ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof RoomsActions.joinRoomFailure>;
@Injectable() @Injectable()
export class RoomsEffects { export class RoomsEffects {
private actions$ = inject(Actions); private actions$ = inject(Actions);
@@ -612,7 +628,7 @@ export class RoomsEffects {
roomId, roomId,
icon, icon,
iconUpdatedAt iconUpdatedAt
} as any); });
return of(RoomsActions.updateServerIconSuccess({ roomId, return of(RoomsActions.updateServerIconSuccess({ roomId,
icon, icon,
@@ -678,17 +694,18 @@ export class RoomsEffects {
mergeMap(([ mergeMap(([
message, message,
currentUser, currentUser,
currentRoom]: [any, any, any currentRoom
]) => { ]) => {
const signalingMessage: RoomPresenceSignalingMessage = message;
const myId = currentUser?.oderId || currentUser?.id; const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id; const viewedServerId = currentRoom?.id;
switch (message.type) { switch (signalingMessage.type) {
case 'server_users': { case 'server_users': {
if (!message.users || isWrongServer(message.serverId, viewedServerId)) if (!signalingMessage.users || isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY; return EMPTY;
const joinActions = (message.users as { oderId: string; displayName: string }[]) const joinActions = signalingMessage.users
.filter((u) => u.oderId !== myId) .filter((u) => u.oderId !== myId)
.map((u) => .map((u) =>
UsersActions.userJoined({ UsersActions.userJoined({
@@ -700,22 +717,33 @@ export class RoomsEffects {
} }
case 'user_joined': { case 'user_joined': {
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) if (isWrongServer(signalingMessage.serverId, viewedServerId) || signalingMessage.oderId === myId)
return EMPTY; return EMPTY;
if (!signalingMessage.oderId)
return EMPTY;
const joinedUser = {
oderId: signalingMessage.oderId,
displayName: signalingMessage.displayName
};
return [ return [
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser(message, buildKnownUserExtras(currentRoom, message.oderId)) user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
}) })
]; ];
} }
case 'user_left': { case 'user_left': {
if (isWrongServer(message.serverId, viewedServerId)) if (isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY; return EMPTY;
this.knownVoiceUsers.delete(message.oderId); if (!signalingMessage.oderId)
return [UsersActions.userLeft({ userId: message.oderId })]; return EMPTY;
this.knownVoiceUsers.delete(signalingMessage.oderId);
return [UsersActions.userLeft({ userId: signalingMessage.oderId })];
} }
default: default:
@@ -811,7 +839,7 @@ export class RoomsEffects {
) )
); );
private handleVoiceOrScreenState(event: any, allUsers: any[], kind: 'voice' | 'screen') { private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], kind: 'voice' | 'screen') {
const userId: string | undefined = event.fromPeerId ?? event.oderId; const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId) if (!userId)
@@ -964,16 +992,17 @@ export class RoomsEffects {
); );
} }
private handleServerStateRequest(event: any, currentRoom: Room | null, savedRooms: Room[]) { private handleServerStateRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const fromPeerId = event.fromPeerId;
if (!room || !event.fromPeerId) if (!room || !fromPeerId)
return EMPTY; return EMPTY;
return from(this.db.getBansForRoom(room.id)).pipe( return from(this.db.getBansForRoom(room.id)).pipe(
tap((bans) => { tap((bans) => {
this.webrtc.sendToPeer(event.fromPeerId, { this.webrtc.sendToPeer(fromPeerId, {
type: 'server-state-full', type: 'server-state-full',
roomId: room.id, roomId: room.id,
room, room,
@@ -985,7 +1014,7 @@ export class RoomsEffects {
} }
private handleServerStateFull( private handleServerStateFull(
event: any, event: ChatEvent,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[], savedRooms: Room[],
currentUser: { id: string; oderId: string } | null currentUser: { id: string; oderId: string } | null
@@ -1002,17 +1031,14 @@ export class RoomsEffects {
return this.syncBansToLocalRoom(room.id, bans).pipe( return this.syncBansToLocalRoom(room.id, bans).pipe(
mergeMap(() => { mergeMap(() => {
const actions: Array< const actions: (ReturnType<typeof RoomsActions.updateRoom>
ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.loadBansSuccess> | ReturnType<typeof UsersActions.loadBansSuccess>
| ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.forgetRoom>)[] = [
> = [
RoomsActions.updateRoom({ RoomsActions.updateRoom({
roomId: room.id, roomId: room.id,
changes: roomChanges changes: roomChanges
}) })
]; ];
const isCurrentUserBanned = hasRoomBanForUser( const isCurrentUserBanned = hasRoomBanForUser(
bans, bans,
currentUser, currentUser,
@@ -1033,7 +1059,7 @@ export class RoomsEffects {
); );
} }
private handleRoomSettingsUpdate(event: any, currentRoom: Room | null, savedRooms: Room[]) { private handleRoomSettingsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const settings = event.settings as Partial<RoomSettings> | undefined; const settings = event.settings as Partial<RoomSettings> | undefined;
@@ -1056,7 +1082,7 @@ export class RoomsEffects {
); );
} }
private handleRoomPermissionsUpdate(event: any, currentRoom: Room | null, savedRooms: Room[]) { private handleRoomPermissionsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const permissions = event.permissions as Partial<RoomPermissions> | undefined; const permissions = event.permissions as Partial<RoomPermissions> | undefined;
@@ -1075,7 +1101,7 @@ export class RoomsEffects {
); );
} }
private handleIconSummary(event: any, currentRoom: Room | null, savedRooms: Room[]) { private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
@@ -1089,13 +1115,13 @@ export class RoomsEffects {
this.webrtc.sendToPeer(event.fromPeerId, { this.webrtc.sendToPeer(event.fromPeerId, {
type: 'server-icon-request', type: 'server-icon-request',
roomId: room.id roomId: room.id
} as any); });
} }
return EMPTY; return EMPTY;
} }
private handleIconRequest(event: any, currentRoom: Room | null, savedRooms: Room[]) { private handleIconRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
@@ -1108,16 +1134,16 @@ export class RoomsEffects {
roomId: room.id, roomId: room.id,
icon: room.icon, icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt || 0 iconUpdatedAt: room.iconUpdatedAt || 0
} as any); });
} }
return EMPTY; return EMPTY;
} }
private handleIconData(event: any, currentRoom: Room | null, savedRooms: Room[]) { private handleIconData(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const senderId = event.fromPeerId as string | undefined; const senderId = event.fromPeerId;
if (!room || typeof event.icon !== 'string' || !senderId) if (!room || typeof event.icon !== 'string' || !senderId)
return EMPTY; return EMPTY;
@@ -1164,7 +1190,7 @@ export class RoomsEffects {
type: 'server-icon-summary', type: 'server-icon-summary',
roomId: room.id, roomId: room.id,
iconUpdatedAt iconUpdatedAt
} as any); });
}) })
), ),
{ dispatch: false } { dispatch: false }
@@ -1177,16 +1203,14 @@ export class RoomsEffects {
private async getBlockedRoomAccessActions( private async getBlockedRoomAccessActions(
roomId: string, roomId: string,
currentUser: { id: string; oderId: string } | null currentUser: { id: string; oderId: string } | null
): Promise<Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>>> { ): Promise<BlockedRoomAccessAction[]> {
const bans = await this.db.getBansForRoom(roomId); const bans = await this.db.getBansForRoom(roomId);
if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) { if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) {
return []; return [];
} }
const blockedActions: Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>> = [ const blockedActions: BlockedRoomAccessAction[] = [RoomsActions.joinRoomFailure({ error: 'You are banned from this server' })];
RoomsActions.joinRoomFailure({ error: 'You are banned from this server' })
];
const storedRoom = await this.db.getRoom(roomId); const storedRoom = await this.db.getRoom(roomId);
if (storedRoom) { if (storedRoom) {

View File

@@ -31,21 +31,25 @@ import {
selectCurrentUserId, selectCurrentUserId,
selectHostId selectHostId
} from './users.selectors'; } from './users.selectors';
import { import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
selectCurrentRoom,
selectSavedRooms
} from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { import {
BanEntry, BanEntry,
ChatEvent,
Room, Room,
User User
} from '../../core/models/index'; } from '../../core/models/index';
import { import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
findRoomMember,
removeRoomMember type IncomingModerationExtraAction =
} from '../rooms/room-members.helpers'; | ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof UsersActions.kickUserSuccess>
| ReturnType<typeof UsersActions.banUserSuccess>;
type IncomingModerationAction =
| ReturnType<typeof RoomsActions.updateRoom>
| IncomingModerationExtraAction;
@Injectable() @Injectable()
export class UsersEffects { export class UsersEffects {
@@ -213,7 +217,6 @@ export class UsersEffects {
const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId); const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
const targetMember = findRoomMember(room.members ?? [], userId); const targetMember = findRoomMember(room.members ?? [], userId);
const nextMembers = removeRoomMember(room.members ?? [], userId, userId); const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
const ban: BanEntry = { const ban: BanEntry = {
oderId: uuidv4(), oderId: uuidv4(),
userId, userId,
@@ -236,10 +239,8 @@ export class UsersEffects {
}); });
}), }),
mergeMap(() => { mergeMap(() => {
const actions: Array< const actions: (ReturnType<typeof RoomsActions.updateRoom>
ReturnType<typeof RoomsActions.updateRoom> | ReturnType<typeof UsersActions.banUserSuccess>)[] = [
| ReturnType<typeof UsersActions.banUserSuccess>
> = [
RoomsActions.updateRoom({ roomId: room.id, RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }) changes: { members: nextMembers } })
]; ];
@@ -433,14 +434,9 @@ export class UsersEffects {
room: Room, room: Room,
targetUserId: string, targetUserId: string,
currentRoom: Room | null, currentRoom: Room | null,
extra: Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof UsersActions.kickUserSuccess> | ReturnType<typeof UsersActions.banUserSuccess>> = [] extra: IncomingModerationExtraAction[] = []
) { ) {
const actions: Array< const actions: IncomingModerationAction[] = [
ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof UsersActions.kickUserSuccess>
| ReturnType<typeof UsersActions.banUserSuccess>
> = [
RoomsActions.updateRoom({ RoomsActions.updateRoom({
roomId: room.id, roomId: room.id,
changes: this.removeMemberFromRoom(room, targetUserId) changes: this.removeMemberFromRoom(room, targetUserId)
@@ -460,7 +456,10 @@ export class UsersEffects {
return currentRoom?.id === room.id; return currentRoom?.id === room.id;
} }
private canForgetForTarget(targetUserId: string, currentUser: User | null): ReturnType<typeof RoomsActions.forgetRoom> | null { private canForgetForTarget(
targetUserId: string,
currentUser: User | null
): ReturnType<typeof RoomsActions.forgetRoom> | null {
return this.isCurrentUserTarget(targetUserId, currentUser) return this.isCurrentUserTarget(targetUserId, currentUser)
? RoomsActions.forgetRoom({ roomId: '' }) ? RoomsActions.forgetRoom({ roomId: '' })
: null; : null;
@@ -470,7 +469,7 @@ export class UsersEffects {
return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId); return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId);
} }
private buildIncomingBan(event: any, targetUserId: string, roomId: string): BanEntry { private buildIncomingBan(event: ChatEvent, targetUserId: string, roomId: string): BanEntry {
const payloadBan = event.ban && typeof event.ban === 'object' const payloadBan = event.ban && typeof event.ban === 'object'
? event.ban as Partial<BanEntry> ? event.ban as Partial<BanEntry>
: null; : null;
@@ -500,7 +499,7 @@ export class UsersEffects {
} }
private handleIncomingKick( private handleIncomingKick(
event: any, event: ChatEvent,
currentUser: User | null, currentUser: User | null,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[] savedRooms: Room[]
@@ -518,15 +517,17 @@ export class UsersEffects {
currentRoom, currentRoom,
this.isCurrentUserTarget(targetUserId, currentUser) this.isCurrentUserTarget(targetUserId, currentUser)
? [RoomsActions.forgetRoom({ roomId: room.id })] ? [RoomsActions.forgetRoom({ roomId: room.id })]
: [UsersActions.kickUserSuccess({ userId: targetUserId, : [
roomId: room.id })] UsersActions.kickUserSuccess({ userId: targetUserId,
roomId: room.id })
]
); );
return actions; return actions;
} }
private handleIncomingBan( private handleIncomingBan(
event: any, event: ChatEvent,
currentUser: User | null, currentUser: User | null,
currentRoom: Room | null, currentRoom: Room | null,
savedRooms: Room[] savedRooms: Room[]
@@ -545,9 +546,11 @@ export class UsersEffects {
currentRoom, currentRoom,
this.isCurrentUserTarget(targetUserId, currentUser) this.isCurrentUserTarget(targetUserId, currentUser)
? [RoomsActions.forgetRoom({ roomId: room.id })] ? [RoomsActions.forgetRoom({ roomId: room.id })]
: [UsersActions.banUserSuccess({ userId: targetUserId, : [
roomId: room.id, UsersActions.banUserSuccess({ userId: targetUserId,
ban })] roomId: room.id,
ban })
]
); );
return from(this.db.saveBan(ban)).pipe( return from(this.db.saveBan(ban)).pipe(
@@ -556,7 +559,7 @@ export class UsersEffects {
); );
} }
private handleIncomingUnban(event: any, currentRoom: Room | null, savedRooms: Room[]) { private handleIncomingUnban(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const banOderId = typeof event.banOderId === 'string' const banOderId = typeof event.banOderId === 'string'

View File

@@ -235,6 +235,7 @@ export const usersReducer = createReducer(
state state
); );
} }
return usersAdapter.updateOne( return usersAdapter.updateOne(
{ {
id: userId, id: userId,