Disallow any types

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

View File

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

View File

@@ -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',

View File

@@ -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<T>(...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') {

View File

@@ -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<any>;
// 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;
}

View File

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

View File

@@ -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<Message>;
data?: string | Partial<Message>;
timestamp?: number;
targetUserId?: string;
roomId?: string;
items?: ChatInventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: Record<string, ChatAttachmentMeta[]>;
total?: number;
index?: number;
count?: number;
lastUpdated?: number;
file?: ChatAttachmentAnnouncement;
fileId?: string;
hostId?: string;
hostOderId?: string;
previousHostId?: string;
@@ -226,6 +269,8 @@ export interface ChatEvent {
permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean;
icon?: string;
iconUpdatedAt?: number;
role?: UserRole;
room?: Room;
channels?: Channel[];

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
import {
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<ChatEvent, 'messageId' | 'file'>;
interface FileChunkPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
total?: number;
data?: ChatEvent['data'];
}
type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
interface AttachmentElectronApi {
getAppDataPath?: () => Promise<string>;
fileExists?: (filePath: string) => Promise<boolean>;
readFile?: (filePath: string) => Promise<string>;
deleteFile?: (filePath: string) => Promise<boolean>;
ensureDir?: (dirPath: string) => Promise<boolean>;
writeFile?: (filePath: string, data: string) => Promise<boolean>;
}
type ElectronWindow = Window & {
electronAPI?: AttachmentElectronApi;
};
type LocalFileWithPath = File & {
path?: string;
};
/**
* Manages peer-to-peer file transfer, local persistence, and
* 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<void> {
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const electronApi = (window as any)?.electronAPI;
const electronApi = this.getElectronApi();
if (!electronApi?.fileExists || !electronApi?.readFile)
return;

View File

@@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
/* eslint-disable, @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@angular/core';
import {
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<void> {
async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
await this.put(STORE_ATTACHMENTS, attachment);
}
/** Return all attachment records for a message. */
async getAttachmentsForMessage(messageId: string): Promise<any[]> {
return this.getAllFromIndex<any>(STORE_ATTACHMENTS, 'messageId', messageId);
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
}
/** Return every persisted attachment record. */
async getAllAttachments(): Promise<any[]> {
return this.getAll<any>(STORE_ATTACHMENTS);
async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
}
/** Delete all attachment records for a message. */
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const attachments = await this.getAllFromIndex<any>(
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
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<void> {
private put<T>(storeName: string, value: T): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName, 'readwrite');

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/member-ordering, */
import {
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); }

View File

@@ -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<void> {
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<any[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line
return this.api.query<any[]>({ type: 'get-attachments-for-message', payload: { messageId } });
}
/** Return every persisted attachment record. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line
getAllAttachments(): Promise<any[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line
return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} });
}

View File

@@ -1,6 +1,14 @@
import { Injectable, inject } from '@angular/core';
import { PlatformService } from './platform.service';
interface ExternalLinkElectronApi {
openExternal?: (url: string) => Promise<boolean>;
}
type ExternalLinkWindow = Window & {
electronAPI?: ExternalLinkElectronApi;
};
/**
* Opens URLs in the system default browser (Electron) or a new tab (browser).
*
@@ -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');
}

View File

@@ -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;
}

View File

@@ -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,9 +123,7 @@ export class VoiceSessionService {
if (!session)
return;
this.store.dispatch(
RoomsActions.viewServer({
room: {
const room: Room = {
id: session.serverId,
name: session.serverName,
description: session.serverDescription,
@@ -134,7 +133,11 @@ export class VoiceSessionService {
userCount: 0,
maxUsers: 50,
icon: session.serverIcon
} as any
};
this.store.dispatch(
RoomsActions.viewServer({
room
})
);

View File

@@ -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<Partial<SignalingMessage>, '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<SignalingMessage>();
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
// Delegates to managers
@@ -175,7 +196,7 @@ export class WebRTCService implements OnDestroy {
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
this.peerManager.activePeerConnections,
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event),
broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event),
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
getIdentifyDisplayName: (): string =>
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
@@ -254,30 +275,63 @@ 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.handleConnectedSignalingMessage(message);
return;
case SIGNALING_TYPE_SERVER_USERS:
this.handleServerUsersSignalingMessage(message);
return;
case SIGNALING_TYPE_USER_JOINED:
this.handleUserJoinedSignalingMessage(message);
return;
case SIGNALING_TYPE_USER_LEFT:
this.handleUserLeftSignalingMessage(message);
return;
case SIGNALING_TYPE_OFFER:
this.handleOfferSignalingMessage(message);
return;
case SIGNALING_TYPE_ANSWER:
this.handleAnswerSignalingMessage(message);
return;
case SIGNALING_TYPE_ICE_CANDIDATE:
this.handleIceCandidateSignalingMessage(message);
return;
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);
}
}
break;
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
const users = Array.isArray(message.users) ? message.users : [];
case SIGNALING_TYPE_SERVER_USERS: {
this.logger.info('Server users', {
count: Array.isArray(message.users) ? message.users.length : 0,
count: users.length,
serverId: message.serverId
});
if (message.users && Array.isArray(message.users)) {
message.users.forEach((user: { oderId: string; displayName: string }) => {
for (const user of users) {
if (!user.oderId)
return;
continue;
const existing = this.peerManager.activePeerConnections.get(user.oderId);
const healthy = this.isPeerHealthy(existing);
@@ -287,7 +341,9 @@ export class WebRTCService implements OnDestroy {
this.peerManager.removePeer(user.oderId);
}
if (!healthy) {
if (healthy)
continue;
this.logger.info('Create peer connection to existing user', {
oderId: user.oderId,
serverId: message.serverId
@@ -300,21 +356,16 @@ export class WebRTCService implements OnDestroy {
this.peerServerMap.set(user.oderId, message.serverId);
}
}
});
}
break;
}
case SIGNALING_TYPE_USER_JOINED:
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('User joined', {
displayName: message.displayName,
oderId: message.oderId
});
}
break;
case SIGNALING_TYPE_USER_LEFT:
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('User left', {
displayName: message.displayName,
oderId: message.oderId,
@@ -325,37 +376,42 @@ export class WebRTCService implements OnDestroy {
this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId);
}
}
break;
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
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);
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
this.peerServerMap.set(fromUserId, offerEffectiveServer);
}
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
this.peerManager.handleOffer(fromUserId, sdp);
}
break;
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
case SIGNALING_TYPE_ANSWER:
if (message.fromUserId && message.payload?.sdp) {
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp);
if (!fromUserId || !sdp)
return;
this.peerManager.handleAnswer(fromUserId, sdp);
}
break;
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const candidate = message.payload?.candidate;
case SIGNALING_TYPE_ICE_CANDIDATE:
if (message.fromUserId && message.payload?.candidate) {
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate);
}
if (!fromUserId || !candidate)
return;
break;
}
this.peerManager.handleIceCandidate(fromUserId, candidate);
}
/**
@@ -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.

View File

@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, id-length */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars,, id-length */
/**
* Manages local voice media: getUserMedia, mute, deafen,
* 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<void>;
/** 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);
});
}

View File

@@ -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';

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */
/* eslint-disable, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */
/**
* Manages screen sharing: getDisplayMedia / Electron desktop capturer,
* 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<DesktopSource[]>;
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
}
type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
maxWidth: number;
maxHeight: number;
maxFrameRate: number;
};
};
type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
};
};
interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
video: ElectronDesktopVideoConstraint;
audio?: false | ElectronDesktopAudioConstraint;
}
type ScreenShareWindow = Window & {
electronAPI?: ScreenShareElectronApi;
};
export class ScreenShareManager {
/** 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<Pick<
ScreenShareElectronApi,
| 'prepareLinuxScreenShareAudioRouting'
| 'activateLinuxScreenShareAudioRouting'
| 'deactivateLinuxScreenShareAudioRouting'
| 'startLinuxScreenShareMonitorCapture'
| 'stopLinuxScreenShareMonitorCapture'
| 'onLinuxScreenShareMonitorAudioChunk'
| 'onLinuxScreenShareMonitorAudioEnded'
>> {
const electronApi = this.getElectronApi();
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|| !electronApi.activateLinuxScreenShareAudioRouting
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|| !electronApi.startLinuxScreenShareMonitorCapture
|| !electronApi.stopLinuxScreenShareMonitorCapture
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
throw new Error('Linux Electron audio routing is unavailable.');
}
return {
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
};
}
private assertLinuxAudioRoutingReady(
routingInfo: LinuxScreenShareAudioRoutingInfo,
unavailableReason: string
): void {
if (!routingInfo.available) {
throw new Error(routingInfo.reason || unavailableReason);
}
if (!routingInfo.monitorCaptureSupported) {
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
}
}
/**
* Create a dedicated stream for system audio captured alongside the screen.
*
@@ -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<MediaStream> {
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<MediaStream> {
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<void> {
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();
}
}

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, max-statements-per-line */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion,, max-statements-per-line */
/**
* Manages the WebSocket connection to the signaling server,
* 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<Partial<SignalingMessage>, 'type' | 'payload'> &
Record<string, unknown> & {
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<void>();
/** Fires whenever a raw signaling message arrives from the server. */
readonly messageReceived$ = new Subject<any>();
readonly messageReceived$ = new Subject<ParsedSignalingMessage>();
/** Fires when connection status changes (true = open, false = closed/error). */
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<string, unknown>;
const message = JSON.parse(rawPayload) as ParsedSignalingMessage;
const payloadPreview = this.buildPayloadPreview(message);
recordDebugNetworkSignalingPayload(message, 'inbound');

View File

@@ -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<string, unknown>): 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
};
}
}

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/member-ordering, */
import { CommonModule } from '@angular/common';
import { 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<Message | undefined>();
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly remarkProcessor: any = REMARK_PROCESSOR;
readonly remarkProcessor = REMARK_PROCESSOR;
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
import {
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
});
})

View File

@@ -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({

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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) {

View File

@@ -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<boolean>;
}
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;
}
}

View File

@@ -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. */

View File

@@ -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

View File

@@ -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<unknown>[] = [];

View File

@@ -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<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
type IncomingMessageType =
| ChatEvent['type']
| 'chat-inventory'
| 'chat-sync-request-ids'
| 'chat-sync-batch'
| 'chat-sync-summary'
| 'chat-sync-request'
| 'chat-sync-full'
| 'file-announce'
| 'file-chunk'
| 'file-request'
| 'file-cancel'
| 'file-not-found';
interface IncomingMessageEvent extends Omit<ChatEvent, 'type'> {
type: IncomingMessageType;
items?: InventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: AttachmentMetaMap;
total?: number;
index?: number;
count?: number;
lastUpdated?: number;
file?: AnnouncedAttachment;
fileId?: string;
}
type SyncBatchEvent = IncomingMessageEvent & {
messages: Message[];
attachments?: AttachmentMetaMap;
};
function hasMessageBatch(event: IncomingMessageEvent): event is SyncBatchEvent {
return Array.isArray(event.messages);
}
function hasAttachmentMetaMap(
attachmentMap: IncomingMessageEvent['attachments']
): attachmentMap is AttachmentMetaMap {
return typeof attachmentMap === 'object' && attachmentMap !== null;
}
/** Shared context injected into each handler function. */
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<Action>;
@@ -57,7 +109,7 @@ type MessageHandler = (
* our local message inventory in chunks.
*/
function handleInventoryRequest(
event: any,
event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> {
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<Action> {
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<Action> {
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<string, any> = {};
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<Action> {
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<Message[]> {
const toUpsert: Message[] = [];
for (const incoming of event.messages as Message[]) {
for (const incoming of event.messages) {
const { message, changed } = await mergeIncomingMessage(incoming, db);
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<string, any[]>,
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<Action> {
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<Action> {
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<Action> {
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<Action> {
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<Action> {
if (!event.messageId || !event.oderId || !event.emoji)
@@ -426,7 +488,7 @@ function handleReactionRemoved(
}
function handleFileAnnounce(
event: any,
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileAnnounce(event);
@@ -434,7 +496,7 @@ function handleFileAnnounce(
}
function handleFileChunk(
event: any,
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileChunk(event);
@@ -442,7 +504,7 @@ function handleFileChunk(
}
function handleFileRequest(
event: any,
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileRequest(event);
@@ -450,7 +512,7 @@ function handleFileRequest(
}
function handleFileCancel(
event: any,
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileCancel(event);
@@ -458,7 +520,7 @@ function handleFileCancel(
}
function handleFileNotFound(
event: any,
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
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<Action> {
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<Action> {
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<Action> {
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<Record<string, MessageHandler>> = {
* Returns `EMPTY` if the event type is unknown or has no relevant handler.
*/
export function dispatchIncomingMessage(
event: any,
event: IncomingMessageEvent,
ctx: IncomingMessageContext
): Observable<Action> {
const handler = HANDLER_MAP[event.type];

View File

@@ -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,

View File

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

View File

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

View File

@@ -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. */

View File

@@ -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[] {

View File

@@ -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<string, unknown> = {}
) {
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<typeof RoomsActions.forgetRoom>
| ReturnType<typeof RoomsActions.joinRoomFailure>;
@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<typeof RoomsActions.updateRoom>
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.loadBansSuccess>
| ReturnType<typeof RoomsActions.forgetRoom>
> = [
| ReturnType<typeof RoomsActions.forgetRoom>)[] = [
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<RoomSettings> | undefined;
@@ -1056,7 +1082,7 @@ export class RoomsEffects {
);
}
private handleRoomPermissionsUpdate(event: any, currentRoom: Room | null, savedRooms: Room[]) {
private handleRoomPermissionsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
@@ -1075,7 +1101,7 @@ export class RoomsEffects {
);
}
private handleIconSummary(event: any, currentRoom: Room | null, savedRooms: Room[]) {
private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const 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<Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>>> {
): Promise<BlockedRoomAccessAction[]> {
const bans = await this.db.getBansForRoom(roomId);
if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) {
return [];
}
const blockedActions: Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>> = [
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) {

View File

@@ -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<typeof RoomsActions.forgetRoom>
| ReturnType<typeof UsersActions.kickUserSuccess>
| ReturnType<typeof UsersActions.banUserSuccess>;
type IncomingModerationAction =
| ReturnType<typeof RoomsActions.updateRoom>
| 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<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>
> = [
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
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<typeof RoomsActions.forgetRoom> | ReturnType<typeof UsersActions.kickUserSuccess> | ReturnType<typeof UsersActions.banUserSuccess>> = []
extra: IncomingModerationExtraAction[] = []
) {
const actions: Array<
ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof UsersActions.kickUserSuccess>
| ReturnType<typeof UsersActions.banUserSuccess>
> = [
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<typeof RoomsActions.forgetRoom> | null {
private canForgetForTarget(
targetUserId: string,
currentUser: User | null
): ReturnType<typeof RoomsActions.forgetRoom> | 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<BanEntry>
: 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,
: [
UsersActions.banUserSuccess({ userId: targetUserId,
roomId: room.id,
ban })]
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'

View File

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