From dc6746c88260c2f44cdd4c0311df9d35ef515bbd Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 9 Mar 2026 23:02:52 +0100 Subject: [PATCH] Disallow any types --- electron/window/create-window.ts | 6 +- eslint.config.js | 2 +- server/src/routes/klipy.ts | 64 ++++- server/src/types/sqljs.d.ts | 6 +- src/app/core/helpers/room-ban.helpers.ts | 7 +- src/app/core/models/index.ts | 47 +++- src/app/core/services/attachment.service.ts | 173 +++++++++---- .../core/services/browser-database.service.ts | 34 +-- src/app/core/services/database.service.ts | 7 +- .../services/electron-database.service.ts | 12 +- .../core/services/external-link.service.ts | 10 +- src/app/core/services/platform.service.ts | 6 +- .../core/services/voice-session.service.ts | 27 +- src/app/core/services/webrtc.service.ts | 232 +++++++++++------- src/app/core/services/webrtc/media.manager.ts | 13 +- .../streams/remote-streams.ts | 5 +- .../services/webrtc/screen-share.manager.ts | 206 ++++++++++++---- .../core/services/webrtc/signaling.manager.ts | 17 +- src/app/core/services/webrtc/webrtc-logger.ts | 49 +++- .../chat-message-item.component.ts | 11 +- .../typing-indicator.component.ts | 20 +- .../room/chat-room/chat-room.component.ts | 5 +- .../rooms-side-panel.component.ts | 7 +- .../servers/servers-rail.component.ts | 5 +- .../bans-settings/bans-settings.component.ts | 5 +- .../members-settings.component.ts | 1 - .../settings-modal.component.ts | 6 +- .../voice-settings.component.ts | 20 +- src/app/features/shell/title-bar.component.ts | 29 ++- .../floating-voice-controls.component.ts | 18 +- .../services/voice-playback.service.ts | 4 +- .../messages/messages-incoming.handlers.ts | 164 +++++++++---- .../store/messages/messages-sync.effects.ts | 10 +- src/app/store/messages/messages.effects.ts | 2 +- src/app/store/messages/messages.helpers.ts | 1 + src/app/store/messages/messages.reducer.ts | 5 +- .../store/rooms/room-members-sync.effects.ts | 39 ++- src/app/store/rooms/rooms.effects.ts | 98 +++++--- src/app/store/users/users.effects.ts | 63 ++--- src/app/store/users/users.reducer.ts | 1 + 40 files changed, 961 insertions(+), 476 deletions(-) diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts index f5b129d..cab96b0 100644 --- a/electron/window/create-window.ts +++ b/electron/window/create-window.ts @@ -1,4 +1,8 @@ -import { app, BrowserWindow, shell } from 'electron'; +import { + app, + BrowserWindow, + shell +} from 'electron'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/eslint.config.js b/eslint.config.js index d8b66fd..a77ab53 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -103,7 +103,7 @@ module.exports = tseslint.config( ] }], '@typescript-eslint/no-empty-function': 'off', '@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-namespace': 'error', '@typescript-eslint/prefer-namespace-keyword': 'error', diff --git a/server/src/routes/klipy.ts b/server/src/routes/klipy.ts index 1c3d7ca..179aab7 100644 --- a/server/src/routes/klipy.ts +++ b/server/src/routes/klipy.ts @@ -1,4 +1,4 @@ -/* eslint-disable complexity, @typescript-eslint/no-explicit-any */ +/* eslint-disable complexity */ import { Router } from 'express'; import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables'; @@ -25,6 +25,28 @@ interface NormalizedKlipyGif { 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(...values: (T | null | undefined)[]): T | undefined { for (const value of values) { if (value != null) @@ -91,16 +113,38 @@ function pickGifMeta(sizeVariant: unknown): NormalizedMediaMeta | null { return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp); } -function normalizeGifItem(item: any): NormalizedKlipyGif | null { - if (!item || typeof item !== 'object' || item.type === 'ad') +function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext: boolean } { + 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; - const lowVariant = pickFirst(item.file?.md, item.file?.sm, item.file?.xs, item.file?.hd); - const highVariant = pickFirst(item.file?.hd, item.file?.md, item.file?.sm, item.file?.xs); + const gifItem = item as KlipyGifItem; + + if (gifItem.type === 'ad') + return null; + + const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd); + const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs); const lowMeta = pickGifMeta(lowVariant); const highMeta = pickGifMeta(highVariant); const selectedMeta = highMeta ?? lowMeta; - const slug = sanitizeString(item.slug) ?? sanitizeString(item.id); + const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id); if (!slug || !selectedMeta?.url) return null; @@ -108,7 +152,7 @@ function normalizeGifItem(item: any): NormalizedKlipyGif | null { return { id: slug, slug, - title: sanitizeString(item.title), + title: sanitizeString(gifItem.title), url: selectedMeta.url, previewUrl: lowMeta?.url ?? selectedMeta.url, 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) - ? (payload as any).data.data - : []; + const { items: rawItems, hasNext } = extractKlipyResponseData(payload); const results = rawItems .map((item: unknown) => normalizeGifItem(item)) .filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item); @@ -206,7 +248,7 @@ router.get('/klipy/gifs', async (req, res) => { res.json({ enabled: true, results, - hasNext: (payload as any)?.data?.has_next === true + hasNext }); } catch (error) { if ((error as { name?: string })?.name === 'AbortError') { diff --git a/server/src/types/sqljs.d.ts b/server/src/types/sqljs.d.ts index 177d7ab..72b138b 100644 --- a/server/src/types/sqljs.d.ts +++ b/server/src/types/sqljs.d.ts @@ -1,9 +1,9 @@ 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; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line export type Database = any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line export type Statement = any; } diff --git a/src/app/core/helpers/room-ban.helpers.ts b/src/app/core/helpers/room-ban.helpers.ts index 755b99d..49fe496 100644 --- a/src/app/core/helpers/room-ban.helpers.ts +++ b/src/app/core/helpers/room-ban.helpers.ts @@ -1,7 +1,4 @@ -import { - BanEntry, - User -} from '../models/index'; +import { BanEntry, User } from '../models/index'; type BanAwareUser = Pick | null | undefined; @@ -42,7 +39,7 @@ export function isRoomBanMatch( /** Return true when any active ban entry targets the provided user. */ export function hasRoomBanForUser( - bans: Array>, + bans: Pick[], user: BanAwareUser, persistedUserId?: string | null ): boolean { diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index e45d09f..86129af 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -178,6 +178,17 @@ export type ChatEventType = | 'room-settings-update' | 'voice-state' | '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' | 'member-leave' @@ -197,6 +208,28 @@ export type ChatEventType = | 'unban' | '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`. */ export interface ChatEvent { type: ChatEventType; @@ -204,10 +237,20 @@ export interface ChatEvent { messageId?: string; message?: Message; reaction?: Reaction; - data?: Partial; + data?: string | Partial; timestamp?: number; targetUserId?: string; roomId?: string; + items?: ChatInventoryItem[]; + ids?: string[]; + messages?: Message[]; + attachments?: Record; + total?: number; + index?: number; + count?: number; + lastUpdated?: number; + file?: ChatAttachmentAnnouncement; + fileId?: string; hostId?: string; hostOderId?: string; previousHostId?: string; @@ -226,6 +269,8 @@ export interface ChatEvent { permissions?: Partial; voiceState?: Partial; isScreenSharing?: boolean; + icon?: string; + iconUpdatedAt?: number; role?: UserRole; room?: Room; channels?: Channel[]; diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index 7732c41..0237104 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -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 { Injectable, inject, @@ -12,6 +12,11 @@ import { Store } from '@ngrx/store'; import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; import { DatabaseService } from './database.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. */ 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. */ -export interface AttachmentMeta { - /** 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; -} +export type AttachmentMeta = ChatAttachmentMeta; /** * Runtime representation of an attachment including download @@ -79,6 +65,72 @@ export interface Attachment extends AttachmentMeta { 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; +interface FileChunkPayload { + messageId?: string; + fileId?: string; + fromPeerId?: string; + index?: number; + total?: number; + data?: ChatEvent['data']; +} +type FileRequestPayload = Pick; +type FileCancelPayload = Pick; +type FileNotFoundPayload = Pick; + +interface AttachmentElectronApi { + getAppDataPath?: () => Promise; + fileExists?: (filePath: string) => Promise; + readFile?: (filePath: string) => Promise; + deleteFile?: (filePath: string) => Promise; + ensureDir?: (dirPath: string) => Promise; + writeFile?: (filePath: string, data: string) => Promise; +} + +type ElectronWindow = Window & { + electronAPI?: AttachmentElectronApi; +}; + +type LocalFileWithPath = File & { + path?: string; +}; + /** * Manages peer-to-peer file transfer, local persistence, and * 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. */ getForMessage(messageId: string): Attachment[] { return this.attachmentsByMessage.get(messageId) ?? []; @@ -285,7 +341,7 @@ export class AttachmentService { /** * Handle a `file-not-found` response - try the next available peer. */ - handleFileNotFound(payload: any): void { + handleFileNotFound(payload: FileNotFoundPayload): void { const { messageId, fileId } = payload; if (!messageId || !fileId) @@ -345,7 +401,7 @@ export class AttachmentService { mime: file.type || DEFAULT_MIME_TYPE, isImage: file.type.startsWith('image/'), uploaderPeerId, - filePath: (file as any)?.path, + filePath: (file as LocalFileWithPath).path, available: false }; @@ -366,7 +422,7 @@ export class AttachmentService { } // Broadcast metadata to peers - this.webrtc.broadcastMessage({ + const fileAnnounceEvent: FileAnnounceEvent = { type: 'file-announce', messageId, file: { @@ -377,7 +433,9 @@ export class AttachmentService { isImage: attachment.isImage, uploaderPeerId } - } as any); + }; + + this.webrtc.broadcastMessage(fileAnnounceEvent); // Auto-stream small inline-preview media 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. */ - handleFileAnnounce(payload: any): void { + handleFileAnnounce(payload: FileAnnouncePayload): void { const { messageId, file } = payload; if (!messageId || !file) @@ -433,14 +491,14 @@ export class AttachmentService { * expected count is reached, at which point the buffers are * 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; if ( !messageId || !fileId || typeof index !== 'number' || typeof total !== 'number' || - !data + typeof data !== 'string' ) return; @@ -538,7 +596,7 @@ export class AttachmentService { * If none of these sources has the file, a `file-not-found` * message is sent so the requester can try another peer. */ - async handleFileRequest(payload: any): Promise { + async handleFileRequest(payload: FileRequestPayload): Promise { const { messageId, fileId, fromPeerId } = payload; if (!messageId || !fileId || !fromPeerId) @@ -566,7 +624,7 @@ export class AttachmentService { const list = this.attachmentsByMessage.get(messageId) ?? []; const attachment = list.find((entry) => entry.id === fileId); - const electronApi = (window as any)?.electronAPI; + const electronApi = this.getElectronApi(); // 2. Electron filePath if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) { @@ -619,11 +677,13 @@ export class AttachmentService { } // 5. File not available locally - this.webrtc.sendToPeer(fromPeerId, { + const fileNotFoundEvent: FileNotFoundEvent = { type: 'file-not-found', messageId, fileId - } as any); + }; + + this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent); } /** @@ -658,11 +718,13 @@ export class AttachmentService { this.touch(); // Notify uploader to stop streaming - this.webrtc.sendToPeer(targetPeerId, { + const fileCancelEvent: FileCancelEvent = { type: 'file-cancel', messageId, fileId: attachment.id - } as any); + }; + + this.webrtc.sendToPeer(targetPeerId, fileCancelEvent); } catch { /* best-effort */ } } @@ -670,7 +732,7 @@ export class AttachmentService { * Handle a `file-cancel` from the requester - record the * cancellation so the streaming loop breaks early. */ - handleFileCancel(payload: any): void { + handleFileCancel(payload: FileCancelPayload): void { const { messageId, fileId, fromPeerId } = payload; if (!messageId || !fileId || !fromPeerId) @@ -774,7 +836,7 @@ export class AttachmentService { } private async deleteSavedFile(filePath: string): Promise { - const electronApi = (window as any)?.electronAPI; + const electronApi = this.getElectronApi(); if (!electronApi?.deleteFile) return; @@ -842,11 +904,13 @@ export class AttachmentService { triedPeers.add(targetPeerId); this.pendingRequests.set(requestKey, triedPeers); - this.webrtc.sendToPeer(targetPeerId, { + const fileRequestEvent: FileRequestEvent = { type: 'file-request', messageId, fileId - } as any); + }; + + this.webrtc.sendToPeer(targetPeerId, fileRequestEvent); return true; } @@ -866,15 +930,16 @@ export class AttachmentService { const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const arrayBuffer = await slice.arrayBuffer(); const base64 = this.arrayBufferToBase64(arrayBuffer); - - this.webrtc.broadcastMessage({ + const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, fileId, index: chunkIndex, total: totalChunks, data: base64 - } as any); + }; + + this.webrtc.broadcastMessage(fileChunkEvent); offset += FILE_CHUNK_SIZE_BYTES; chunkIndex++; @@ -900,15 +965,16 @@ export class AttachmentService { const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const arrayBuffer = await slice.arrayBuffer(); const base64 = this.arrayBufferToBase64(arrayBuffer); - - await this.webrtc.sendToPeerBuffered(targetPeerId, { + const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, fileId, index: chunkIndex, total: totalChunks, data: base64 - } as any); + }; + + await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); offset += FILE_CHUNK_SIZE_BYTES; chunkIndex++; @@ -925,7 +991,11 @@ export class AttachmentService { fileId: string, diskPath: string ): Promise { - const electronApi = (window as any)?.electronAPI; + const electronApi = this.getElectronApi(); + + if (!electronApi?.readFile) + return; + const base64Full = await electronApi.readFile(diskPath); const fileBytes = this.base64ToUint8Array(base64Full); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); @@ -942,15 +1012,16 @@ export class AttachmentService { slice.byteOffset + slice.byteLength ); const base64Chunk = this.arrayBufferToBase64(sliceBuffer); - - this.webrtc.sendToPeer(targetPeerId, { + const fileChunkEvent: FileChunkEvent = { type: 'file-chunk', messageId, fileId, index: chunkIndex, total: totalChunks, data: base64Chunk - } as any); + }; + + this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); } } @@ -960,10 +1031,10 @@ export class AttachmentService { */ private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise { try { - const electronApi = (window as any)?.electronAPI; + const electronApi = this.getElectronApi(); const appDataPath: string | undefined = await electronApi?.getAppDataPath?.(); - if (!appDataPath) + if (!appDataPath || !electronApi?.ensureDir || !electronApi.writeFile) return; const roomName = await this.resolveCurrentRoomName(); @@ -992,7 +1063,7 @@ export class AttachmentService { /** On startup, try loading previously saved files from disk (Electron). */ private async tryLoadSavedFiles(): Promise { - const electronApi = (window as any)?.electronAPI; + const electronApi = this.getElectronApi(); if (!electronApi?.fileExists || !electronApi?.readFile) return; diff --git a/src/app/core/services/browser-database.service.ts b/src/app/core/services/browser-database.service.ts index 5289ee8..12ffd4c 100644 --- a/src/app/core/services/browser-database.service.ts +++ b/src/app/core/services/browser-database.service.ts @@ -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 { DELETED_MESSAGE_CONTENT, + ChatAttachmentMeta, Message, User, Room, @@ -234,10 +235,7 @@ export class BrowserDatabaseService { const match = allBans.find((ban) => ban.oderId === oderId); if (match) { - await this.deleteRecord( - STORE_BANS, - (match as any).id ?? match.oderId - ); + await this.deleteRecord(STORE_BANS, match.oderId); } } @@ -265,23 +263,23 @@ export class BrowserDatabaseService { } /** Persist an attachment metadata record. */ - async saveAttachment(attachment: any): Promise { + async saveAttachment(attachment: ChatAttachmentMeta): Promise { await this.put(STORE_ATTACHMENTS, attachment); } /** Return all attachment records for a message. */ - async getAttachmentsForMessage(messageId: string): Promise { - return this.getAllFromIndex(STORE_ATTACHMENTS, 'messageId', messageId); + async getAttachmentsForMessage(messageId: string): Promise { + return this.getAllFromIndex(STORE_ATTACHMENTS, 'messageId', messageId); } /** Return every persisted attachment record. */ - async getAllAttachments(): Promise { - return this.getAll(STORE_ATTACHMENTS); + async getAllAttachments(): Promise { + return this.getAll(STORE_ATTACHMENTS); } /** Delete all attachment records for a message. */ async deleteAttachmentsForMessage(messageId: string): Promise { - const attachments = await this.getAllFromIndex( + const attachments = await this.getAllFromIndex( STORE_ATTACHMENTS, 'messageId', messageId ); @@ -308,9 +306,7 @@ export class BrowserDatabaseService { await this.awaitTransaction(transaction); } - // ══════════════════════════════════════════════════════════════════ // Private helpers - thin wrappers around IndexedDB - // ══════════════════════════════════════════════════════════════════ /** * Open (or upgrade) the IndexedDB database and create any missing @@ -370,7 +366,15 @@ export class BrowserDatabaseService { stores: string | string[], mode: IDBTransactionMode = 'readonly' ): 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. */ @@ -420,7 +424,7 @@ export class BrowserDatabaseService { } /** Insert or update a record in the given object store. */ - private put(storeName: string, value: any): Promise { + private put(storeName: string, value: T): Promise { return new Promise((resolve, reject) => { const transaction = this.createTransaction(storeName, 'readwrite'); diff --git a/src/app/core/services/database.service.ts b/src/app/core/services/database.service.ts index e5ea89a..f68bbdc 100644 --- a/src/app/core/services/database.service.ts +++ b/src/app/core/services/database.service.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/member-ordering, */ import { inject, Injectable, @@ -9,7 +9,8 @@ import { User, Room, Reaction, - BanEntry + BanEntry, + ChatAttachmentMeta } from '../models/index'; import { PlatformService } from './platform.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); } /** 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. */ getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); } diff --git a/src/app/core/services/electron-database.service.ts b/src/app/core/services/electron-database.service.ts index d5e06b3..fad4c46 100644 --- a/src/app/core/services/electron-database.service.ts +++ b/src/app/core/services/electron-database.service.ts @@ -28,7 +28,7 @@ interface ElectronAPI { export class ElectronDatabaseService { /** Shorthand accessor for the preload-exposed CQRS API. */ private get api(): ElectronAPI { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line return (window as any).electronAPI as ElectronAPI; } @@ -165,22 +165,22 @@ export class ElectronDatabaseService { } /** Persist attachment metadata. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line saveAttachment(attachment: any): Promise { return this.api.command({ type: 'save-attachment', payload: { attachment } }); } /** Return all attachment records for a message. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line getAttachmentsForMessage(messageId: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line return this.api.query({ type: 'get-attachments-for-message', payload: { messageId } }); } /** Return every persisted attachment record. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line getAllAttachments(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line return this.api.query({ type: 'get-all-attachments', payload: {} }); } diff --git a/src/app/core/services/external-link.service.ts b/src/app/core/services/external-link.service.ts index 63fa8ad..1c73289 100644 --- a/src/app/core/services/external-link.service.ts +++ b/src/app/core/services/external-link.service.ts @@ -1,6 +1,14 @@ import { Injectable, inject } from '@angular/core'; import { PlatformService } from './platform.service'; +interface ExternalLinkElectronApi { + openExternal?: (url: string) => Promise; +} + +type ExternalLinkWindow = Window & { + electronAPI?: ExternalLinkElectronApi; +}; + /** * Opens URLs in the system default browser (Electron) or a new tab (browser). * @@ -17,7 +25,7 @@ export class ExternalLinkService { return; if (this.platform.isElectron) { - (window as any).electronAPI?.openExternal(url); + (window as ExternalLinkWindow).electronAPI?.openExternal?.(url); } else { window.open(url, '_blank', 'noopener,noreferrer'); } diff --git a/src/app/core/services/platform.service.ts b/src/app/core/services/platform.service.ts index 6310acb..e73b78f 100644 --- a/src/app/core/services/platform.service.ts +++ b/src/app/core/services/platform.service.ts @@ -1,5 +1,9 @@ import { Injectable } from '@angular/core'; +type ElectronPlatformWindow = Window & { + electronAPI?: unknown; +}; + @Injectable({ providedIn: 'root' }) export class PlatformService { readonly isElectron: boolean; @@ -7,7 +11,7 @@ export class PlatformService { constructor() { this.isElectron = - typeof window !== 'undefined' && !!(window as any).electronAPI; + typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI; this.isBrowser = !this.isElectron; } diff --git a/src/app/core/services/voice-session.service.ts b/src/app/core/services/voice-session.service.ts index 03f6231..ef72ec5 100644 --- a/src/app/core/services/voice-session.service.ts +++ b/src/app/core/services/voice-session.service.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/member-ordering, */ import { Injectable, signal, @@ -7,6 +7,7 @@ import { } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; +import { Room } from '../models'; import { RoomsActions } from '../../store/rooms/rooms.actions'; /** @@ -122,19 +123,21 @@ export class VoiceSessionService { if (!session) 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( RoomsActions.viewServer({ - room: { - id: session.serverId, - name: session.serverName, - description: session.serverDescription, - hostId: '', - isPrivate: false, - createdAt: 0, - userCount: 0, - maxUsers: 50, - icon: session.serverIcon - } as any + room }) ); diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index d3b3c54..c35d709 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -11,7 +11,7 @@ * This file wires them together and exposes a public API that is * 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 { Injectable, signal, @@ -54,6 +54,27 @@ import { P2P_TYPE_SCREEN_STATE } from './webrtc'; +interface SignalingUserSummary { + oderId: string; + displayName: string; +} + +interface IncomingSignalingPayload { + sdp?: RTCSessionDescriptionInit; + candidate?: RTCIceCandidateInit; +} + +type IncomingSignalingMessage = Omit, 'type' | 'payload'> & { + type: string; + payload?: IncomingSignalingPayload; + oderId?: string; + serverTime?: number; + serverId?: string; + users?: SignalingUserSummary[]; + displayName?: string; + fromUserId?: string; +}; + @Injectable({ providedIn: 'root' }) @@ -120,7 +141,7 @@ export class WebRTCService implements OnDestroy { /** Per-peer latency map (ms). Read via `peerLatencies()`. */ readonly peerLatencies = computed(() => this._peerLatencies()); - private readonly signalingMessage$ = new Subject(); + private readonly signalingMessage$ = new Subject(); readonly onSignalingMessage = this.signalingMessage$.asObservable(); // Delegates to managers @@ -175,7 +196,7 @@ export class WebRTCService implements OnDestroy { getActivePeers: (): Map => this.peerManager.activePeerConnections, renegotiate: (peerId: string): Promise => 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(), getIdentifyDisplayName: (): string => 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.logger.info('Signaling message', { type: message.type }); switch (message.type) { case SIGNALING_TYPE_CONNECTED: - this.logger.info('Server connected', { oderId: message.oderId }); + this.handleConnectedSignalingMessage(message); + return; - if (typeof message.serverTime === 'number') { - this.timeSync.setFromServerTime(message.serverTime); - } - - 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_SERVER_USERS: + this.handleServerUsersSignalingMessage(message); + return; case SIGNALING_TYPE_USER_JOINED: - this.logger.info('User joined', { - displayName: message.displayName, - oderId: message.oderId - }); - - break; + this.handleUserJoinedSignalingMessage(message); + return; case SIGNALING_TYPE_USER_LEFT: - 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); - } - - break; + this.handleUserLeftSignalingMessage(message); + return; case SIGNALING_TYPE_OFFER: - if (message.fromUserId && message.payload?.sdp) { - // Track inbound peer as belonging to our effective server - 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; + this.handleOfferSignalingMessage(message); + return; case SIGNALING_TYPE_ANSWER: - if (message.fromUserId && message.payload?.sdp) { - this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp); - } - - break; + this.handleAnswerSignalingMessage(message); + return; case SIGNALING_TYPE_ICE_CANDIDATE: - if (message.fromUserId && message.payload?.candidate) { - this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate); - } + this.handleIceCandidateSignalingMessage(message); + 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 * 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 - // ═══════════════════════════════════════════════════════════════════ /** * Connect to a signaling server via WebSocket. diff --git a/src/app/core/services/webrtc/media.manager.ts b/src/app/core/services/webrtc/media.manager.ts index 55ff5d6..aa36f35 100644 --- a/src/app/core/services/webrtc/media.manager.ts +++ b/src/app/core/services/webrtc/media.manager.ts @@ -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, * attaching/detaching audio tracks to peer connections, bitrate tuning, * and optional RNNoise-based noise reduction. */ import { Subject } from 'rxjs'; +import { ChatEvent } from '../../models'; import { WebRTCLogger } from './webrtc-logger'; import { PeerData } from './webrtc.types'; import { NoiseReductionManager } from './noise-reduction.manager'; @@ -35,7 +36,7 @@ export interface MediaManagerCallbacks { /** Trigger SDP renegotiation for a specific peer. */ renegotiate(peerId: string): Promise; /** Broadcast a message to all peers. */ - broadcastMessage(event: any): void; + broadcastMessage(event: ChatEvent): void; /** Get identify credentials (for broadcasting). */ getIdentifyOderId(): string; getIdentifyDisplayName(): string; @@ -410,7 +411,7 @@ export class MediaManager { try { params = sender.getParameters(); } catch (error) { - this.logger.warn('getParameters failed; skipping bitrate apply', error as any); + this.logger.warn('getParameters failed; skipping bitrate apply', error); return; } @@ -421,7 +422,7 @@ export class MediaManager { await sender.setParameters(params); this.logger.info('Applied audio bitrate', { targetBps }); } 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.inputGainNode?.disconnect(); } 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') { 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); }); } diff --git a/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts b/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts index 6b8c771..bad190e 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts +++ b/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts @@ -1,7 +1,4 @@ -import { - TRACK_KIND_AUDIO, - TRACK_KIND_VIDEO -} from '../../webrtc.constants'; +import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO } from '../../webrtc.constants'; import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service'; import { PeerConnectionManagerContext } from '../shared'; diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/core/services/webrtc/screen-share.manager.ts index 7febcd5..6481b68 100644 --- a/src/app/core/services/webrtc/screen-share.manager.ts +++ b/src/app/core/services/webrtc/screen-share.manager.ts @@ -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, * system-audio capture, and attaching screen tracks to peers. @@ -71,6 +71,49 @@ interface LinuxScreenShareMonitorAudioPipeline { unsubscribeEnded: () => void; } +interface DesktopSource { + id: string; + name: string; + thumbnail: string; +} + +interface ScreenShareElectronApi { + getSources?: () => Promise; + prepareLinuxScreenShareAudioRouting?: () => Promise; + activateLinuxScreenShareAudioRouting?: () => Promise; + deactivateLinuxScreenShareAudioRouting?: () => Promise; + startLinuxScreenShareMonitorCapture?: () => Promise; + stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise; + 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 { /** The active screen-capture stream. */ private activeScreenStream: MediaStream | null = null; @@ -105,10 +148,10 @@ export class ScreenShareManager { * Replace the callback set at runtime. * 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 { - this.callbacks = cb; + setCallbacks(nextCallbacks: ScreenShareCallbacks): void { + this.callbacks = nextCallbacks; } /** Returns the current screen-capture stream, or `null` if inactive. */ @@ -151,7 +194,7 @@ export class ScreenShareManager { try { this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset); } 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; } } 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 { this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset); } 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.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) { screenVideoTrack.onended = () => { @@ -198,7 +247,7 @@ export class ScreenShareManager { }; } - return this.activeScreenStream!; + return activeScreenStream; } catch (error) { this.logger.error('Failed to start screen share', error); throw error; @@ -287,6 +336,63 @@ export class ScreenShareManager { 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> { + 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. * @@ -450,15 +556,26 @@ export class ScreenShareManager { 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( options: ScreenShareStartOptions, preset: ScreenShareQualityPreset ): Promise { - const sources = await (window as any).electronAPI.getSources(); - const screenSource = sources.find((source: any) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0]; + const electronApi = this.getElectronApi(); + + 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); this.logger.info('desktopCapturer constraints', electronConstraints); @@ -475,7 +592,7 @@ export class ScreenShareManager { return false; } - const electronApi = (window as any).electronAPI; + const electronApi = this.getElectronApi(); const platformHint = `${navigator.userAgent} ${navigator.platform}`; return !!electronApi?.prepareLinuxScreenShareAudioRouting @@ -492,28 +609,20 @@ export class ScreenShareManager { options: ScreenShareStartOptions, preset: ScreenShareQualityPreset ): Promise { - const electronApi = (window as any).electronAPI; - const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting() as LinuxScreenShareAudioRoutingInfo; + const electronApi = this.getRequiredLinuxElectronApi(); + const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting(); - if (!routingInfo?.available) { - 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.'); - } + this.assertLinuxAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.'); let desktopStream: MediaStream | null = null; try { - const activation = await electronApi.activateLinuxScreenShareAudioRouting() as LinuxScreenShareAudioRoutingInfo; + const activation = await electronApi.activateLinuxScreenShareAudioRouting(); - if (!activation?.available || !activation.active) { - throw new Error(activation?.reason || 'Failed to activate Linux Electron audio routing.'); - } + this.assertLinuxAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.'); - if (!activation.monitorCaptureSupported) { - throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.'); + if (!activation.active) { + throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.'); } desktopStream = await this.startWithElectronDesktopCapturer({ @@ -547,7 +656,7 @@ export class ScreenShareManager { this.linuxAudioRoutingResetPromise = this.resetLinuxElectronAudioRouting() .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(() => { this.linuxAudioRoutingResetPromise = null; @@ -563,7 +672,7 @@ export class ScreenShareManager { } private async resetLinuxElectronAudioRouting(): Promise { - const electronApi = typeof window !== 'undefined' ? (window as any).electronAPI : null; + const electronApi = this.getElectronApi(); const captureId = this.linuxMonitorAudioPipeline?.captureId; this.linuxElectronAudioRoutingActive = false; @@ -575,7 +684,7 @@ export class ScreenShareManager { await electronApi.stopLinuxScreenShareMonitorCapture(captureId); } } 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 { @@ -583,7 +692,7 @@ export class ScreenShareManager { await electronApi.deactivateLinuxScreenShareAudioRouting(); } } 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; captureInfo: LinuxScreenShareMonitorCaptureInfo; }> { - const electronApi = (window as any).electronAPI; + const electronApi = this.getElectronApi(); if (!electronApi?.startLinuxScreenShareMonitorCapture || !electronApi?.stopLinuxScreenShareMonitorCapture @@ -626,7 +735,7 @@ export class ScreenShareManager { 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) { this.stopScreenShare(); @@ -664,17 +773,19 @@ export class ScreenShareManager { }; this.linuxMonitorAudioPipeline = pipeline; + const activeCaptureId = captureInfo.captureId; audioTrack.addEventListener('ended', () => { - if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === captureInfo?.captureId) { + if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === activeCaptureId) { this.stopScreenShare(); } }, { once: true }); const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || []; + const activePipeline = pipeline; queuedChunks.forEach((chunk) => { - this.handleLinuxScreenShareMonitorAudioChunk(pipeline!, chunk); + this.handleLinuxScreenShareMonitorAudioChunk(activePipeline, chunk); }); queuedChunksByCaptureId.delete(captureInfo.captureId); @@ -699,7 +810,7 @@ export class ScreenShareManager { try { await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId); } 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; @@ -724,7 +835,7 @@ export class ScreenShareManager { pipeline.pendingBytes = new Uint8Array(0); 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', { bitsPerSample: pipeline.bitsPerSample, captureId: pipeline.captureId - } as any); + }); return; } @@ -762,7 +873,7 @@ export class ScreenShareManager { if (pipeline.audioContext.state !== 'running') { 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, options: ScreenShareStartOptions, preset: ScreenShareQualityPreset - ): MediaStreamConstraints { - const electronConstraints: any = { + ): ElectronDesktopMediaStreamConstraints { + const electronConstraints: ElectronDesktopMediaStreamConstraints = { video: { mandatory: { chromeMediaSource: 'desktop', @@ -915,7 +1026,7 @@ export class ScreenShareManager { height: { ideal: preset.height, max: preset.height }, frameRate: { ideal: preset.frameRate, max: preset.frameRate } }).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 }); } 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(); - } } diff --git a/src/app/core/services/webrtc/signaling.manager.ts b/src/app/core/services/webrtc/signaling.manager.ts index e7b5c27..15ba64a 100644 --- a/src/app/core/services/webrtc/signaling.manager.ts +++ b/src/app/core/services/webrtc/signaling.manager.ts @@ -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, * including automatic reconnection and heartbeats. @@ -18,6 +18,17 @@ import { SIGNALING_TYPE_VIEW_SERVER } from './webrtc.constants'; +interface ParsedSignalingPayload { + sdp?: RTCSessionDescriptionInit; + candidate?: RTCIceCandidateInit; +} + +type ParsedSignalingMessage = Omit, 'type' | 'payload'> & + Record & { + type: string; + payload?: ParsedSignalingPayload; + }; + export class SignalingManager { private signalingWebSocket: WebSocket | null = null; private lastSignalingUrl: string | null = null; @@ -29,7 +40,7 @@ export class SignalingManager { readonly heartbeatTick$ = new Subject(); /** Fires whenever a raw signaling message arrives from the server. */ - readonly messageReceived$ = new Subject(); + readonly messageReceived$ = new Subject(); /** Fires when connection status changes (true = open, false = closed/error). */ readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>(); @@ -73,7 +84,7 @@ export class SignalingManager { const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null; try { - const message = JSON.parse(rawPayload) as SignalingMessage & Record; + const message = JSON.parse(rawPayload) as ParsedSignalingMessage; const payloadPreview = this.buildPayloadPreview(message); recordDebugNetworkSignalingPayload(message, 'inbound'); diff --git a/src/app/core/services/webrtc/webrtc-logger.ts b/src/app/core/services/webrtc/webrtc-logger.ts index ca0654e..ccedf94 100644 --- a/src/app/core/services/webrtc/webrtc-logger.ts +++ b/src/app/core/services/webrtc/webrtc-logger.ts @@ -4,6 +4,7 @@ * All log lines are prefixed with `[WebRTC]`. */ export interface WebRTCTrafficDetails { + [key: string]: unknown; bytes?: number; bufferedAmount?: number; channelLabel?: string; @@ -15,21 +16,14 @@ export interface WebRTCTrafficDetails { targetPeerId?: string; type?: string; url?: string | null; - [key: string]: unknown; } export class WebRTCLogger { 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). */ info(prefix: string, ...args: unknown[]): void { - if (!this.debugEnabled) + if (!this.isDebugEnabled()) return; try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } @@ -37,7 +31,7 @@ export class WebRTCLogger { /** Warning log (only when debug is enabled). */ warn(prefix: string, ...args: unknown[]): void { - if (!this.debugEnabled) + if (!this.isDebugEnabled()) return; try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } @@ -50,10 +44,11 @@ export class WebRTCLogger { /** Error log (always emitted regardless of debug flag). */ error(prefix: string, err: unknown, extra?: Record): void { + const errorDetails = this.extractErrorDetails(err); const payload = { - name: (err as any)?.name, - message: (err as any)?.message, - stack: (err as any)?.stack, + name: errorDetails.name, + message: errorDetails.message, + stack: errorDetails.stack, ...extra }; @@ -93,7 +88,7 @@ export class WebRTCLogger { const videoTracks = stream.getVideoTracks(); this.info(`Stream ready: ${label}`, { - id: (stream as any).id, + id: stream.id, audioTrackCount: audioTracks.length, videoTrackCount: videoTracks.length, 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}`)); 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 + }; + } } diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts index cb08f8b..0de80ce 100644 --- a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts +++ b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts @@ -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 { FormsModule } from '@angular/forms'; import { @@ -33,10 +33,7 @@ import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../../../../core/services/attachment.service'; import { KlipyService } from '../../../../../core/services/klipy.service'; -import { - DELETED_MESSAGE_CONTENT, - Message -} from '../../../../../core/models'; +import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models'; import { ChatAudioPlayerComponent, ChatVideoPlayerComponent, @@ -81,7 +78,7 @@ const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g; const REMARK_PROCESSOR = unified() .use(remarkParse) .use(remarkGfm) - .use(remarkBreaks) as any; + .use(remarkBreaks); interface ChatMessageAttachmentViewModel extends Attachment { isAudio: boolean; @@ -133,7 +130,7 @@ export class ChatMessageItemComponent { readonly repliedMessage = input(); readonly currentUserId = input(null); readonly isAdmin = input(false); - readonly remarkProcessor: any = REMARK_PROCESSOR; + readonly remarkProcessor = REMARK_PROCESSOR; readonly replyRequested = output(); readonly deleteRequested = output(); diff --git a/src/app/features/chat/typing-indicator/typing-indicator.component.ts b/src/app/features/chat/typing-indicator/typing-indicator.component.ts index a5f1084..1b97d90 100644 --- a/src/app/features/chat/typing-indicator/typing-indicator.component.ts +++ b/src/app/features/chat/typing-indicator/typing-indicator.component.ts @@ -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 { Component, inject, @@ -19,6 +19,12 @@ const TYPING_TTL = 3_000; const PURGE_INTERVAL = 1_000; const MAX_SHOWN = 4; +interface TypingSignalingMessage { + type: string; + displayName: string; + oderId: string; +} + @Component({ selector: 'app-typing-indicator', standalone: true, @@ -38,12 +44,16 @@ export class TypingIndicatorComponent { const webrtc = inject(WebRTCService); const destroyRef = inject(DestroyRef); const typing$ = webrtc.onSignalingMessage.pipe( - filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId), - tap((msg: any) => { + filter((msg): msg is TypingSignalingMessage => + msg?.type === 'user_typing' && + typeof msg.displayName === 'string' && + typeof msg.oderId === 'string' + ), + tap((msg) => { const now = Date.now(); - this.typingMap.set(String(msg.oderId), { - name: String(msg.displayName), + this.typingMap.set(msg.oderId, { + name: msg.displayName, expiresAt: now + TYPING_TTL }); }) diff --git a/src/app/features/room/chat-room/chat-room.component.ts b/src/app/features/room/chat-room/chat-room.component.ts index c04f200..8c6238c 100644 --- a/src/app/features/room/chat-room/chat-room.component.ts +++ b/src/app/features/room/chat-room/chat-room.component.ts @@ -29,10 +29,7 @@ import { selectVoiceChannels } from '../../../store/rooms/rooms.selectors'; import { SettingsModalService } from '../../../core/services/settings-modal.service'; -import { - selectCurrentUser, - selectIsCurrentUserAdmin -} from '../../../store/users/users.selectors'; +import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; @Component({ diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index 53e540b..41f82a7 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -549,10 +549,13 @@ export class RoomsSidePanelComponent { return false; } - const peerKeys = [user?.oderId, user?.id, userId].filter( + const peerKeys = [ + user?.oderId, + user?.id, + userId + ].filter( (candidate): candidate is string => !!candidate ); - const stream = peerKeys .map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey)) .find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null; diff --git a/src/app/features/servers/servers-rail.component.ts b/src/app/features/servers/servers-rail.component.ts index 74e1037..bd8cb9a 100644 --- a/src/app/features/servers/servers-rail.component.ts +++ b/src/app/features/servers/servers-rail.component.ts @@ -12,10 +12,7 @@ import { Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePlus } from '@ng-icons/lucide'; -import { - Room, - User -} from '../../core/models/index'; +import { Room, User } from '../../core/models/index'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors'; import { VoiceSessionService } from '../../core/services/voice-session.service'; diff --git a/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts b/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts index a87088a..0aa20b4 100644 --- a/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts +++ b/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts @@ -8,10 +8,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; -import { - Actions, - ofType -} from '@ngrx/effects'; +import { Actions, ofType } from '@ngrx/effects'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { Store } from '@ngrx/store'; import { lucideX } from '@ng-icons/lucide'; diff --git a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts index 17150c7..4922a07 100644 --- a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts +++ b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts @@ -14,7 +14,6 @@ import { lucideUserX, lucideBan } from '@ng-icons/lucide'; import { Room, RoomMember, - User, UserRole } from '../../../../core/models/index'; import { RoomsActions } from '../../../../store/rooms/rooms.actions'; diff --git a/src/app/features/settings/settings-modal/settings-modal.component.ts b/src/app/features/settings/settings-modal/settings-modal.component.ts index f83151f..7925d00 100644 --- a/src/app/features/settings/settings-modal/settings-modal.component.ts +++ b/src/app/features/settings/settings-modal/settings-modal.component.ts @@ -26,10 +26,7 @@ import { import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors'; -import { - Room, - UserRole -} from '../../../core/models/index'; +import { Room, UserRole } from '../../../core/models/index'; import { findRoomMember } from '../../../store/rooms/room-members.helpers'; import { WebRTCService } from '../../../core/services/webrtc.service'; @@ -191,7 +188,6 @@ export class SettingsModalComponent { const targetId = this.modal.targetServerId(); const currentRoomId = this.currentRoom()?.id ?? null; const selectedId = this.selectedServerId(); - const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId); if (!hasSelected) { diff --git a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts index 2821aa5..ef73b98 100644 --- a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts +++ b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts @@ -20,14 +20,8 @@ import { WebRTCService } from '../../../../core/services/webrtc.service'; import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { PlatformService } from '../../../../core/services/platform.service'; -import { - loadVoiceSettingsFromStorage, - saveVoiceSettingsToStorage -} from '../../../../core/services/voice-settings.storage'; -import { - SCREEN_SHARE_QUALITY_OPTIONS, - ScreenShareQuality -} from '../../../../core/services/webrtc'; +import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../core/services/voice-settings.storage'; +import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../core/services/webrtc'; interface AudioDevice { deviceId: string; @@ -46,6 +40,10 @@ interface DesktopSettingsElectronApi { relaunchApp?: () => Promise; } +type DesktopSettingsWindow = Window & { + electronAPI?: DesktopSettingsElectronApi; +}; + @Component({ selector: 'app-voice-settings', standalone: true, @@ -87,7 +85,9 @@ export class VoiceSettingsComponent { askScreenShareQuality = signal(true); hardwareAcceleration = signal(true); 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() { this.loadVoiceSettings(); @@ -294,7 +294,7 @@ export class VoiceSettingsComponent { private getElectronApi(): DesktopSettingsElectronApi | null { return typeof window !== 'undefined' - ? (window as any).electronAPI as DesktopSettingsElectronApi + ? (window as DesktopSettingsWindow).electronAPI ?? null : null; } } diff --git a/src/app/features/shell/title-bar.component.ts b/src/app/features/shell/title-bar.component.ts index cf0c9f7..4e6559c 100644 --- a/src/app/features/shell/title-bar.component.ts +++ b/src/app/features/shell/title-bar.component.ts @@ -26,6 +26,16 @@ import { PlatformService } from '../../core/services/platform.service'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { LeaveServerDialogComponent } from '../../shared'; +interface WindowControlsAPI { + minimizeWindow?: () => void; + maximizeWindow?: () => void; + closeWindow?: () => void; +} + +type ElectronWindow = Window & { + electronAPI?: WindowControlsAPI; +}; + @Component({ selector: 'app-title-bar', standalone: true, @@ -54,6 +64,10 @@ export class TitleBarComponent { private webrtc = inject(WebRTCService); private platform = inject(PlatformService); + private getWindowControlsApi(): WindowControlsAPI | undefined { + return (window as ElectronWindow).electronAPI; + } + isElectron = computed(() => this.platform.isElectron); showMenuState = computed(() => false); @@ -73,26 +87,23 @@ export class TitleBarComponent { /** Minimize the Electron window. */ 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() { - const api = (window as any).electronAPI; + const api = this.getWindowControlsApi(); - if (api?.maximizeWindow) - api.maximizeWindow(); + api?.maximizeWindow?.(); } /** Close the Electron window. */ close() { - const api = (window as any).electronAPI; + const api = this.getWindowControlsApi(); - if (api?.closeWindow) - api.closeWindow(); + api?.closeWindow?.(); } /** Navigate to the login page. */ diff --git a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts index 9ece5b8..d2dc1f5 100644 --- a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts +++ b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts @@ -23,22 +23,21 @@ import { import { WebRTCService } from '../../../core/services/webrtc.service'; import { VoiceSessionService } from '../../../core/services/voice-session.service'; -import { - loadVoiceSettingsFromStorage, - saveVoiceSettingsToStorage -} from '../../../core/services/voice-settings.storage'; +import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; import { ScreenShareQuality } from '../../../core/services/webrtc'; import { UsersActions } from '../../../store/users/users.actions'; import { selectCurrentUser } from '../../../store/users/users.selectors'; -import { - DebugConsoleComponent, - ScreenShareQualityDialogComponent -} from '../../../shared'; +import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared'; @Component({ selector: 'app-floating-voice-controls', standalone: true, - imports: [CommonModule, NgIcon, DebugConsoleComponent, ScreenShareQualityDialogComponent], + imports: [ + CommonModule, + NgIcon, + DebugConsoleComponent, + ScreenShareQualityDialogComponent + ], viewProviders: [ provideIcons({ lucideMic, @@ -283,6 +282,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy { includeSystemAudio: this.includeSystemAudio(), quality }); + this.isScreenSharing.set(true); } catch (_error) { // Screen share request was denied or failed diff --git a/src/app/features/voice/voice-controls/services/voice-playback.service.ts b/src/app/features/voice/voice-controls/services/voice-playback.service.ts index 7324d40..1e4eba4 100644 --- a/src/app/features/voice/voice-controls/services/voice-playback.service.ts +++ b/src/app/features/voice/voice-controls/services/voice-playback.service.ts @@ -183,9 +183,9 @@ export class VoicePlaybackService { return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line 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 tasks: Promise[] = []; diff --git a/src/app/store/messages/messages-incoming.handlers.ts b/src/app/store/messages/messages-incoming.handlers.ts index db5436b..0da7b36 100644 --- a/src/app/store/messages/messages-incoming.handlers.ts +++ b/src/app/store/messages/messages-incoming.handlers.ts @@ -17,17 +17,24 @@ import { } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; 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 { DatabaseService } from '../../core/services/database.service'; import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; 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 { INVENTORY_LIMIT, CHUNK_SIZE, FULL_SYNC_LIMIT, + type InventoryItem, chunkArray, buildInventoryItem, buildLocalInventoryMap, @@ -36,19 +43,64 @@ import { mergeIncomingMessage } from './messages.helpers'; +type AnnouncedAttachment = Pick; +type AttachmentMetaMap = Record; +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 { + 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. */ export interface IncomingMessageContext { db: DatabaseService; webrtc: WebRTCService; attachments: AttachmentService; debugging: DebuggingService; - currentUser: any; - currentRoom: any; + currentUser: User | null; + currentRoom: Room | null; } /** Signature for an incoming-message handler function. */ type MessageHandler = ( - event: any, + event: IncomingMessageEvent, ctx: IncomingMessageContext, ) => Observable; @@ -57,7 +109,7 @@ type MessageHandler = ( * our local message inventory in chunks. */ function handleInventoryRequest( - event: any, + event: IncomingMessageEvent, { db, webrtc, attachments }: IncomingMessageContext ): Observable { const { roomId, fromPeerId } = event; @@ -83,13 +135,15 @@ function handleInventoryRequest( items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts); for (const chunk of chunkArray(items, CHUNK_SIZE)) { - webrtc.sendToPeer(fromPeerId, { + const inventoryEvent: IncomingMessageEvent = { type: 'chat-inventory', roomId, items: chunk, total: items.length, index: 0 - } as any); + }; + + webrtc.sendToPeer(fromPeerId, inventoryEvent); } })() ).pipe(mergeMap(() => EMPTY)); @@ -100,7 +154,7 @@ function handleInventoryRequest( * and requests any missing or stale messages. */ function handleInventory( - event: any, + event: IncomingMessageEvent, { db, webrtc, attachments }: IncomingMessageContext ): Observable { const { roomId, fromPeerId, items } = event; @@ -125,11 +179,13 @@ function handleInventory( const missing = findMissingIds(items, localMap); for (const chunk of chunkArray(missing, CHUNK_SIZE)) { - webrtc.sendToPeer(fromPeerId, { + const syncRequestIdsEvent: IncomingMessageEvent = { type: 'chat-sync-request-ids', roomId, ids: chunk - } as any); + }; + + webrtc.sendToPeer(fromPeerId, syncRequestIdsEvent); } })() ).pipe(mergeMap(() => EMPTY)); @@ -140,7 +196,7 @@ function handleInventory( * hydrated messages along with their attachment metadata. */ function handleSyncRequestIds( - event: any, + event: IncomingMessageEvent, { db, webrtc, attachments }: IncomingMessageContext ): Observable { const { roomId, ids, fromPeerId } = event; @@ -164,14 +220,14 @@ function handleSyncRequestIds( attachments.getAttachmentMetasForMessages(msgIds); for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) { - const chunkAttachments: Record = {}; + const chunkAttachments: AttachmentMetaMap = {}; for (const hydratedMessage of chunk) { if (attachmentMetas[hydratedMessage.id]) chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id]; } - webrtc.sendToPeer(fromPeerId, { + const syncBatchEvent: IncomingMessageEvent = { type: 'chat-sync-batch', roomId: roomId || '', messages: chunk, @@ -179,7 +235,9 @@ function handleSyncRequestIds( Object.keys(chunkAttachments).length > 0 ? chunkAttachments : undefined - } as any); + }; + + webrtc.sendToPeer(fromPeerId, syncBatchEvent); } })() ).pipe(mergeMap(() => EMPTY)); @@ -191,13 +249,13 @@ function handleSyncRequestIds( * missing image attachments. */ function handleSyncBatch( - event: any, + event: IncomingMessageEvent, { db, attachments }: IncomingMessageContext ): Observable { - if (!Array.isArray(event.messages)) + if (!hasMessageBatch(event)) return EMPTY; - if (event.attachments && typeof event.attachments === 'object') { + if (hasAttachmentMetaMap(event.attachments)) { attachments.registerSyncedAttachments(event.attachments); } @@ -212,13 +270,13 @@ function handleSyncBatch( /** Merges each incoming message and collects those that changed. */ async function processSyncBatch( - event: any, + event: SyncBatchEvent, db: DatabaseService, attachments: AttachmentService ): Promise { const toUpsert: Message[] = []; - for (const incoming of event.messages as Message[]) { + for (const incoming of event.messages) { const { message, changed } = await mergeIncomingMessage(incoming, db); if (incoming.isDeleted) { @@ -233,7 +291,7 @@ async function processSyncBatch( toUpsert.push(message); } - if (event.attachments && typeof event.attachments === 'object') { + if (hasAttachmentMetaMap(event.attachments)) { requestMissingImages(event.attachments, attachments); } @@ -242,7 +300,7 @@ async function processSyncBatch( /** Auto-requests any unavailable image attachments from any connected peer. */ function requestMissingImages( - attachmentMap: Record, + attachmentMap: AttachmentMetaMap, attachments: AttachmentService ): void { for (const [msgId, metas] of Object.entries(attachmentMap)) { @@ -251,7 +309,7 @@ function requestMissingImages( continue; 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 ( matchingAttachment && @@ -266,7 +324,7 @@ function requestMissingImages( /** Saves an incoming chat message to DB and dispatches receiveMessage. */ function handleChatMessage( - event: any, + event: IncomingMessageEvent, { db, debugging, currentUser }: IncomingMessageContext ): Observable { const msg = event.message; @@ -300,21 +358,25 @@ function handleChatMessage( /** Applies a remote message edit to the local DB and store. */ function handleMessageEdited( - event: any, + event: IncomingMessageEvent, { db, debugging }: IncomingMessageContext ): Observable { if (!event.messageId || !event.content) return EMPTY; + const editedAt = typeof event.editedAt === 'number' + ? event.editedAt + : Date.now(); + trackBackgroundOperation( db.updateMessage(event.messageId, { content: event.content, - editedAt: event.editedAt + editedAt }), debugging, 'Failed to persist incoming message edit', { - editedAt: event.editedAt ?? null, + editedAt, fromPeerId: event.fromPeerId ?? null, messageId: event.messageId } @@ -324,14 +386,14 @@ function handleMessageEdited( MessagesActions.editMessageSuccess({ messageId: event.messageId, content: event.content, - editedAt: event.editedAt + editedAt }) ); } /** Applies a remote message deletion to the local DB and store. */ function handleMessageDeleted( - event: any, + event: IncomingMessageEvent, { db, debugging, attachments }: IncomingMessageContext ): Observable { if (!event.messageId) @@ -375,7 +437,7 @@ function handleMessageDeleted( /** Saves an incoming reaction to DB and updates the store. */ function handleReactionAdded( - event: any, + event: IncomingMessageEvent, { db, debugging }: IncomingMessageContext ): Observable { if (!event.messageId || !event.reaction) @@ -398,7 +460,7 @@ function handleReactionAdded( /** Removes a reaction from DB and updates the store. */ function handleReactionRemoved( - event: any, + event: IncomingMessageEvent, { db, debugging }: IncomingMessageContext ): Observable { if (!event.messageId || !event.oderId || !event.emoji) @@ -426,7 +488,7 @@ function handleReactionRemoved( } function handleFileAnnounce( - event: any, + event: IncomingMessageEvent, { attachments }: IncomingMessageContext ): Observable { attachments.handleFileAnnounce(event); @@ -434,7 +496,7 @@ function handleFileAnnounce( } function handleFileChunk( - event: any, + event: IncomingMessageEvent, { attachments }: IncomingMessageContext ): Observable { attachments.handleFileChunk(event); @@ -442,7 +504,7 @@ function handleFileChunk( } function handleFileRequest( - event: any, + event: IncomingMessageEvent, { attachments }: IncomingMessageContext ): Observable { attachments.handleFileRequest(event); @@ -450,7 +512,7 @@ function handleFileRequest( } function handleFileCancel( - event: any, + event: IncomingMessageEvent, { attachments }: IncomingMessageContext ): Observable { attachments.handleFileCancel(event); @@ -458,7 +520,7 @@ function handleFileCancel( } function handleFileNotFound( - event: any, + event: IncomingMessageEvent, { attachments }: IncomingMessageContext ): Observable { attachments.handleFileNotFound(event); @@ -470,7 +532,7 @@ function handleFileNotFound( * if the peer has newer or more data. */ function handleSyncSummary( - event: any, + event: IncomingMessageEvent, { db, webrtc, currentRoom }: IncomingMessageContext ): Observable { if (!currentRoom) @@ -491,12 +553,15 @@ function handleSyncSummary( const needsSync = remoteLastUpdated > localLastUpdated || (remoteLastUpdated === localLastUpdated && remoteCount > localCount); + const fromPeerId = event.fromPeerId; - if (!identical && needsSync && event.fromPeerId) { - webrtc.sendToPeer(event.fromPeerId, { + if (!identical && needsSync && fromPeerId) { + const syncRequestEvent: IncomingMessageEvent = { type: 'chat-sync-request', roomId: currentRoom.id - } as any); + }; + + webrtc.sendToPeer(fromPeerId, syncRequestEvent); } })() ).pipe(mergeMap(() => EMPTY)); @@ -504,31 +569,34 @@ function handleSyncSummary( /** Responds to a peer's full sync request by sending all local messages. */ function handleSyncRequest( - event: any, + event: IncomingMessageEvent, { db, webrtc, currentRoom }: IncomingMessageContext ): Observable { - if (!currentRoom || !event.fromPeerId) + const fromPeerId = event.fromPeerId; + + if (!currentRoom || !fromPeerId) return EMPTY; return from( (async () => { const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0); - - webrtc.sendToPeer(event.fromPeerId, { + const syncFullEvent: IncomingMessageEvent = { type: 'chat-sync-full', roomId: currentRoom.id, messages: all - } as any); + }; + + webrtc.sendToPeer(fromPeerId, syncFullEvent); })() ).pipe(mergeMap(() => EMPTY)); } /** Merges a full message dump from a peer into the local DB and store. */ function handleSyncFull( - event: any, + event: IncomingMessageEvent, { db, attachments }: IncomingMessageContext ): Observable { - if (!event.messages || !Array.isArray(event.messages)) + if (!hasMessageBatch(event)) return EMPTY; return from(processSyncBatch(event, db, attachments)).pipe( @@ -575,7 +643,7 @@ const HANDLER_MAP: Readonly> = { * Returns `EMPTY` if the event type is unknown or has no relevant handler. */ export function dispatchIncomingMessage( - event: any, + event: IncomingMessageEvent, ctx: IncomingMessageContext ): Observable { const handler = HANDLER_MAP[event.type]; diff --git a/src/app/store/messages/messages-sync.effects.ts b/src/app/store/messages/messages-sync.effects.ts index bc29f07..32998fb 100644 --- a/src/app/store/messages/messages-sync.effects.ts +++ b/src/app/store/messages/messages-sync.effects.ts @@ -89,12 +89,12 @@ export class MessagesSyncEffects { roomId: room.id, count, lastUpdated - } as any); + }); this.webrtc.sendToPeer(peerId, { type: 'chat-inventory-request', roomId: room.id - } as any); + }); }) ); }) @@ -131,12 +131,12 @@ export class MessagesSyncEffects { roomId: activeRoom.id, count, lastUpdated - } as any); + }); this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: activeRoom.id - } as any); + }); } catch (error) { this.debugging.warn('messages', 'Failed to kick off room sync for peer', { error, @@ -186,7 +186,7 @@ export class MessagesSyncEffects { this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id - } as any); + }); } catch (error) { this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', { error, diff --git a/src/app/store/messages/messages.effects.ts b/src/app/store/messages/messages.effects.ts index 5689f77..e8a8e5d 100644 --- a/src/app/store/messages/messages.effects.ts +++ b/src/app/store/messages/messages.effects.ts @@ -383,7 +383,7 @@ export class MessagesEffects { webrtc: this.webrtc, attachments: this.attachments, debugging: this.debugging, - currentUser, + currentUser: currentUser ?? null, currentRoom }; diff --git a/src/app/store/messages/messages.helpers.ts b/src/app/store/messages/messages.helpers.ts index be0231d..ef8b1a8 100644 --- a/src/app/store/messages/messages.helpers.ts +++ b/src/app/store/messages/messages.helpers.ts @@ -134,6 +134,7 @@ export async function buildLocalInventoryMap( rc: 0, ac: 0 }); + return; } diff --git a/src/app/store/messages/messages.reducer.ts b/src/app/store/messages/messages.reducer.ts index a8fb2d4..6fdfb3a 100644 --- a/src/app/store/messages/messages.reducer.ts +++ b/src/app/store/messages/messages.reducer.ts @@ -4,10 +4,7 @@ import { EntityAdapter, createEntityAdapter } from '@ngrx/entity'; -import { - DELETED_MESSAGE_CONTENT, - Message -} from '../../core/models/index'; +import { DELETED_MESSAGE_CONTENT, Message } from '../../core/models/index'; import { MessagesActions } from './messages.actions'; /** State shape for the messages feature slice, extending NgRx EntityState. */ diff --git a/src/app/store/rooms/room-members-sync.effects.ts b/src/app/store/rooms/room-members-sync.effects.ts index c3f4d0d..fc3ff04 100644 --- a/src/app/store/rooms/room-members-sync.effects.ts +++ b/src/app/store/rooms/room-members-sync.effects.ts @@ -14,6 +14,7 @@ import { withLatestFrom } from 'rxjs/operators'; import { + ChatEvent, Room, RoomMember, User @@ -127,7 +128,13 @@ export class RoomMembersSyncEffects { savedRooms, 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); @@ -159,9 +166,14 @@ export class RoomMembersSyncEffects { if (!signalingMessage.oderId || signalingMessage.oderId === myId) return EMPTY; + const joinedUser = { + oderId: signalingMessage.oderId, + displayName: signalingMessage.displayName + }; + const members = upsertRoomMember( room.members ?? [], - this.buildPresenceMember(room, signalingMessage) + this.buildPresenceMember(room, joinedUser) ); const actions = this.createRoomMemberUpdateActions(room, members); @@ -197,7 +209,7 @@ export class RoomMembersSyncEffects { this.webrtc.sendToPeer(peerId, { type: 'member-roster-request', roomId: currentRoom.id - } as any); + }); }) ), { dispatch: false } @@ -218,7 +230,7 @@ export class RoomMembersSyncEffects { this.webrtc.sendToPeer(peerId, { type: 'member-roster-request', roomId: room.id - } as any); + }); } catch { /* peer may have disconnected */ } @@ -333,7 +345,7 @@ export class RoomMembersSyncEffects { } private handleMemberRosterRequest( - event: any, + event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null @@ -358,13 +370,13 @@ export class RoomMembersSyncEffects { type: 'member-roster', roomId: room.id, members - } as any); + }); return this.createRoomMemberUpdateActions(room, members); } private handleMemberRoster( - event: any, + event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null @@ -387,7 +399,7 @@ export class RoomMembersSyncEffects { } private handleMemberLeave( - event: any, + event: ChatEvent, currentRoom: Room | null, savedRooms: Room[] ): Action[] { @@ -401,10 +413,11 @@ export class RoomMembersSyncEffects { room, 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( - UsersActions.userLeft({ userId: event.oderId || event.targetUserId }) + UsersActions.userLeft({ userId: departedUserId }) ); } @@ -412,7 +425,7 @@ export class RoomMembersSyncEffects { } private handleIncomingHostChange( - event: any, + event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null @@ -434,7 +447,7 @@ export class RoomMembersSyncEffects { } : null, { - id: event.previousHostId, + id: event.previousHostId ?? event.previousHostOderId ?? '', oderId: event.previousHostOderId } ); @@ -484,7 +497,7 @@ export class RoomMembersSyncEffects { } private handleIncomingRoleChange( - event: any, + event: ChatEvent, currentRoom: Room | null, savedRooms: Room[] ): Action[] { diff --git a/src/app/store/rooms/rooms.effects.ts b/src/app/store/rooms/rooms.effects.ts index 6442640..b6f8c8a 100644 --- a/src/app/store/rooms/rooms.effects.ts +++ b/src/app/store/rooms/rooms.effects.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/member-ordering */ /* 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 { Router } from '@angular/router'; import { @@ -34,10 +34,12 @@ import { DatabaseService } from '../../core/services/database.service'; import { WebRTCService } from '../../core/services/webrtc.service'; import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { + ChatEvent, Room, RoomSettings, RoomPermissions, BanEntry, + User, VoiceState } from '../../core/models/index'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; @@ -50,14 +52,16 @@ import { /** Build a minimal User object from signaling payload. */ function buildSignalingUser( - data: { oderId: string; displayName: string }, + data: { oderId: string; displayName?: string }, extras: Record = {} ) { + const displayName = data.displayName || 'User'; + return { oderId: data.oderId, id: data.oderId, - username: data.displayName.toLowerCase().replace(/\s+/g, '_'), - displayName: data.displayName, + username: displayName.toLowerCase().replace(/\s+/g, '_'), + displayName, status: 'online' as const, isOnline: true, role: 'member' as const, @@ -89,6 +93,18 @@ function isWrongServer( return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId); } +interface RoomPresenceSignalingMessage { + type: string; + serverId?: string; + users?: { oderId: string; displayName: string }[]; + oderId?: string; + displayName?: string; +} + +type BlockedRoomAccessAction = + | ReturnType + | ReturnType; + @Injectable() export class RoomsEffects { private actions$ = inject(Actions); @@ -612,7 +628,7 @@ export class RoomsEffects { roomId, icon, iconUpdatedAt - } as any); + }); return of(RoomsActions.updateServerIconSuccess({ roomId, icon, @@ -678,17 +694,18 @@ export class RoomsEffects { mergeMap(([ message, currentUser, - currentRoom]: [any, any, any + currentRoom ]) => { + const signalingMessage: RoomPresenceSignalingMessage = message; const myId = currentUser?.oderId || currentUser?.id; const viewedServerId = currentRoom?.id; - switch (message.type) { + switch (signalingMessage.type) { case 'server_users': { - if (!message.users || isWrongServer(message.serverId, viewedServerId)) + if (!signalingMessage.users || isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY; - const joinActions = (message.users as { oderId: string; displayName: string }[]) + const joinActions = signalingMessage.users .filter((u) => u.oderId !== myId) .map((u) => UsersActions.userJoined({ @@ -700,22 +717,33 @@ export class RoomsEffects { } case 'user_joined': { - if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) + if (isWrongServer(signalingMessage.serverId, viewedServerId) || signalingMessage.oderId === myId) return EMPTY; + if (!signalingMessage.oderId) + return EMPTY; + + const joinedUser = { + oderId: signalingMessage.oderId, + displayName: signalingMessage.displayName + }; + return [ UsersActions.userJoined({ - user: buildSignalingUser(message, buildKnownUserExtras(currentRoom, message.oderId)) + user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId)) }) ]; } case 'user_left': { - if (isWrongServer(message.serverId, viewedServerId)) + if (isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY; - this.knownVoiceUsers.delete(message.oderId); - return [UsersActions.userLeft({ userId: message.oderId })]; + if (!signalingMessage.oderId) + return EMPTY; + + this.knownVoiceUsers.delete(signalingMessage.oderId); + return [UsersActions.userLeft({ userId: signalingMessage.oderId })]; } 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; 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); + const fromPeerId = event.fromPeerId; - if (!room || !event.fromPeerId) + if (!room || !fromPeerId) return EMPTY; return from(this.db.getBansForRoom(room.id)).pipe( tap((bans) => { - this.webrtc.sendToPeer(event.fromPeerId, { + this.webrtc.sendToPeer(fromPeerId, { type: 'server-state-full', roomId: room.id, room, @@ -985,7 +1014,7 @@ export class RoomsEffects { } private handleServerStateFull( - event: any, + event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: { id: string; oderId: string } | null @@ -1002,17 +1031,14 @@ export class RoomsEffects { return this.syncBansToLocalRoom(room.id, bans).pipe( mergeMap(() => { - const actions: Array< - ReturnType + const actions: (ReturnType | ReturnType - | ReturnType - > = [ + | ReturnType)[] = [ RoomsActions.updateRoom({ roomId: room.id, changes: roomChanges }) ]; - const isCurrentUserBanned = hasRoomBanForUser( bans, 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); const settings = event.settings as Partial | 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); const permissions = event.permissions as Partial | 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); @@ -1089,13 +1115,13 @@ export class RoomsEffects { this.webrtc.sendToPeer(event.fromPeerId, { type: 'server-icon-request', roomId: room.id - } as any); + }); } 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); @@ -1108,16 +1134,16 @@ export class RoomsEffects { roomId: room.id, icon: room.icon, iconUpdatedAt: room.iconUpdatedAt || 0 - } as any); + }); } 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); - const senderId = event.fromPeerId as string | undefined; + const senderId = event.fromPeerId; if (!room || typeof event.icon !== 'string' || !senderId) return EMPTY; @@ -1164,7 +1190,7 @@ export class RoomsEffects { type: 'server-icon-summary', roomId: room.id, iconUpdatedAt - } as any); + }); }) ), { dispatch: false } @@ -1177,16 +1203,14 @@ export class RoomsEffects { private async getBlockedRoomAccessActions( roomId: string, currentUser: { id: string; oderId: string } | null - ): Promise | ReturnType>> { + ): Promise { const bans = await this.db.getBansForRoom(roomId); if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) { return []; } - const blockedActions: Array | ReturnType> = [ - RoomsActions.joinRoomFailure({ error: 'You are banned from this server' }) - ]; + const blockedActions: BlockedRoomAccessAction[] = [RoomsActions.joinRoomFailure({ error: 'You are banned from this server' })]; const storedRoom = await this.db.getRoom(roomId); if (storedRoom) { diff --git a/src/app/store/users/users.effects.ts b/src/app/store/users/users.effects.ts index 17e62ff..8c52ef2 100644 --- a/src/app/store/users/users.effects.ts +++ b/src/app/store/users/users.effects.ts @@ -31,21 +31,25 @@ import { selectCurrentUserId, selectHostId } from './users.selectors'; -import { - selectCurrentRoom, - selectSavedRooms -} from '../rooms/rooms.selectors'; +import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors'; import { DatabaseService } from '../../core/services/database.service'; import { WebRTCService } from '../../core/services/webrtc.service'; import { BanEntry, + ChatEvent, Room, User } from '../../core/models/index'; -import { - findRoomMember, - removeRoomMember -} from '../rooms/room-members.helpers'; +import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers'; + +type IncomingModerationExtraAction = + | ReturnType + | ReturnType + | ReturnType; + +type IncomingModerationAction = + | ReturnType + | IncomingModerationExtraAction; @Injectable() export class UsersEffects { @@ -213,7 +217,6 @@ export class UsersEffects { const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId); const targetMember = findRoomMember(room.members ?? [], userId); const nextMembers = removeRoomMember(room.members ?? [], userId, userId); - const ban: BanEntry = { oderId: uuidv4(), userId, @@ -236,10 +239,8 @@ export class UsersEffects { }); }), mergeMap(() => { - const actions: Array< - ReturnType - | ReturnType - > = [ + const actions: (ReturnType + | ReturnType)[] = [ RoomsActions.updateRoom({ roomId: room.id, changes: { members: nextMembers } }) ]; @@ -433,14 +434,9 @@ export class UsersEffects { room: Room, targetUserId: string, currentRoom: Room | null, - extra: Array | ReturnType | ReturnType> = [] + extra: IncomingModerationExtraAction[] = [] ) { - const actions: Array< - ReturnType - | ReturnType - | ReturnType - | ReturnType - > = [ + const actions: IncomingModerationAction[] = [ RoomsActions.updateRoom({ roomId: room.id, changes: this.removeMemberFromRoom(room, targetUserId) @@ -460,7 +456,10 @@ export class UsersEffects { return currentRoom?.id === room.id; } - private canForgetForTarget(targetUserId: string, currentUser: User | null): ReturnType | null { + private canForgetForTarget( + targetUserId: string, + currentUser: User | null + ): ReturnType | null { return this.isCurrentUserTarget(targetUserId, currentUser) ? RoomsActions.forgetRoom({ roomId: '' }) : null; @@ -470,7 +469,7 @@ export class UsersEffects { 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' ? event.ban as Partial : null; @@ -500,7 +499,7 @@ export class UsersEffects { } private handleIncomingKick( - event: any, + event: ChatEvent, currentUser: User | null, currentRoom: Room | null, savedRooms: Room[] @@ -518,15 +517,17 @@ export class UsersEffects { currentRoom, this.isCurrentUserTarget(targetUserId, currentUser) ? [RoomsActions.forgetRoom({ roomId: room.id })] - : [UsersActions.kickUserSuccess({ userId: targetUserId, - roomId: room.id })] + : [ + UsersActions.kickUserSuccess({ userId: targetUserId, + roomId: room.id }) + ] ); return actions; } private handleIncomingBan( - event: any, + event: ChatEvent, currentUser: User | null, currentRoom: Room | null, savedRooms: Room[] @@ -545,9 +546,11 @@ export class UsersEffects { currentRoom, this.isCurrentUserTarget(targetUserId, currentUser) ? [RoomsActions.forgetRoom({ roomId: room.id })] - : [UsersActions.banUserSuccess({ userId: targetUserId, - roomId: room.id, - ban })] + : [ + UsersActions.banUserSuccess({ userId: targetUserId, + roomId: room.id, + ban }) + ] ); 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 room = this.resolveRoom(roomId, currentRoom, savedRooms); const banOderId = typeof event.banOderId === 'string' diff --git a/src/app/store/users/users.reducer.ts b/src/app/store/users/users.reducer.ts index 0d1c877..56f61ab 100644 --- a/src/app/store/users/users.reducer.ts +++ b/src/app/store/users/users.reducer.ts @@ -235,6 +235,7 @@ export const usersReducer = createReducer( state ); } + return usersAdapter.updateOne( { id: userId,