Disallow any types
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') {
|
||||
|
||||
6
server/src/types/sqljs.d.ts
vendored
6
server/src/types/sqljs.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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: {} });
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Room } from '../models';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
|
||||
/**
|
||||
@@ -122,19 +123,21 @@ export class VoiceSessionService {
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
const room: Room = {
|
||||
id: session.serverId,
|
||||
name: session.serverName,
|
||||
description: session.serverDescription,
|
||||
hostId: '',
|
||||
isPrivate: false,
|
||||
createdAt: 0,
|
||||
userCount: 0,
|
||||
maxUsers: 50,
|
||||
icon: session.serverIcon
|
||||
};
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.viewServer({
|
||||
room: {
|
||||
id: session.serverId,
|
||||
name: session.serverName,
|
||||
description: session.serverDescription,
|
||||
hostId: '',
|
||||
isPrivate: false,
|
||||
createdAt: 0,
|
||||
userCount: 0,
|
||||
maxUsers: 50,
|
||||
icon: session.serverIcon
|
||||
} as any
|
||||
room
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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,110 +275,145 @@ export class WebRTCService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private handleSignalingMessage(message: any): void {
|
||||
private handleSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.signalingMessage$.next(message);
|
||||
this.logger.info('Signaling message', { type: message.type });
|
||||
|
||||
switch (message.type) {
|
||||
case SIGNALING_TYPE_CONNECTED:
|
||||
this.logger.info('Server connected', { oderId: message.oderId });
|
||||
this.handleConnectedSignalingMessage(message);
|
||||
return;
|
||||
|
||||
if (typeof message.serverTime === 'number') {
|
||||
this.timeSync.setFromServerTime(message.serverTime);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SIGNALING_TYPE_SERVER_USERS: {
|
||||
this.logger.info('Server users', {
|
||||
count: Array.isArray(message.users) ? message.users.length : 0,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.users && Array.isArray(message.users)) {
|
||||
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
||||
if (!user.oderId)
|
||||
return;
|
||||
|
||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||
const healthy = this.isPeerHealthy(existing);
|
||||
|
||||
if (existing && !healthy) {
|
||||
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
||||
this.peerManager.removePeer(user.oderId);
|
||||
}
|
||||
|
||||
if (!healthy) {
|
||||
this.logger.info('Create peer connection to existing user', {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
this.peerManager.createPeerConnection(user.oderId, true);
|
||||
this.peerManager.createAndSendOffer(user.oderId);
|
||||
|
||||
if (message.serverId) {
|
||||
this.peerServerMap.set(user.oderId, message.serverId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case SIGNALING_TYPE_SERVER_USERS:
|
||||
this.handleServerUsersSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_JOINED:
|
||||
this.logger.info('User joined', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId
|
||||
});
|
||||
|
||||
break;
|
||||
this.handleUserJoinedSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_LEFT:
|
||||
this.logger.info('User left', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.oderId) {
|
||||
this.peerManager.removePeer(message.oderId);
|
||||
this.peerServerMap.delete(message.oderId);
|
||||
}
|
||||
|
||||
break;
|
||||
this.handleUserLeftSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_OFFER:
|
||||
if (message.fromUserId && message.payload?.sdp) {
|
||||
// Track inbound peer as belonging to our effective server
|
||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||
|
||||
if (offerEffectiveServer && !this.peerServerMap.has(message.fromUserId)) {
|
||||
this.peerServerMap.set(message.fromUserId, offerEffectiveServer);
|
||||
}
|
||||
|
||||
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
|
||||
}
|
||||
|
||||
break;
|
||||
this.handleOfferSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ANSWER:
|
||||
if (message.fromUserId && message.payload?.sdp) {
|
||||
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp);
|
||||
}
|
||||
|
||||
break;
|
||||
this.handleAnswerSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||
if (message.fromUserId && message.payload?.candidate) {
|
||||
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate);
|
||||
}
|
||||
this.handleIceCandidateSignalingMessage(message);
|
||||
return;
|
||||
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.logger.info('Server connected', { oderId: message.oderId });
|
||||
|
||||
if (typeof message.serverTime === 'number') {
|
||||
this.timeSync.setFromServerTime(message.serverTime);
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const users = Array.isArray(message.users) ? message.users : [];
|
||||
|
||||
this.logger.info('Server users', {
|
||||
count: users.length,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.oderId)
|
||||
continue;
|
||||
|
||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||
const healthy = this.isPeerHealthy(existing);
|
||||
|
||||
if (existing && !healthy) {
|
||||
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
||||
this.peerManager.removePeer(user.oderId);
|
||||
}
|
||||
|
||||
if (healthy)
|
||||
continue;
|
||||
|
||||
this.logger.info('Create peer connection to existing user', {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
this.peerManager.createPeerConnection(user.oderId, true);
|
||||
this.peerManager.createAndSendOffer(user.oderId);
|
||||
|
||||
if (message.serverId) {
|
||||
this.peerServerMap.set(user.oderId, message.serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.logger.info('User joined', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId
|
||||
});
|
||||
}
|
||||
|
||||
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.logger.info('User left', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.oderId) {
|
||||
this.peerManager.removePeer(message.oderId);
|
||||
this.peerServerMap.delete(message.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||
|
||||
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
||||
this.peerServerMap.set(fromUserId, offerEffectiveServer);
|
||||
}
|
||||
|
||||
this.peerManager.handleOffer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
this.peerManager.handleAnswer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const candidate = message.payload?.candidate;
|
||||
|
||||
if (!fromUserId || !candidate)
|
||||
return;
|
||||
|
||||
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all peer connections that were discovered from a server
|
||||
* other than `serverId`. Also removes their entries from
|
||||
@@ -395,9 +451,7 @@ export class WebRTCService implements OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PUBLIC API - matches the old monolithic service's interface
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Connect to a signaling server via WebSocket.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>[] = [];
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -383,7 +383,7 @@ export class MessagesEffects {
|
||||
webrtc: this.webrtc,
|
||||
attachments: this.attachments,
|
||||
debugging: this.debugging,
|
||||
currentUser,
|
||||
currentUser: currentUser ?? null,
|
||||
currentRoom
|
||||
};
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function buildLocalInventoryMap(
|
||||
rc: 0,
|
||||
ac: 0
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
roomId: room.id,
|
||||
ban })]
|
||||
: [
|
||||
UsersActions.banUserSuccess({ userId: targetUserId,
|
||||
roomId: room.id,
|
||||
ban })
|
||||
]
|
||||
);
|
||||
|
||||
return from(this.db.saveBan(ban)).pipe(
|
||||
@@ -556,7 +559,7 @@ export class UsersEffects {
|
||||
);
|
||||
}
|
||||
|
||||
private handleIncomingUnban(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||
private handleIncomingUnban(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const banOderId = typeof event.banOderId === 'string'
|
||||
|
||||
@@ -235,6 +235,7 @@ export const usersReducer = createReducer(
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
|
||||
Reference in New Issue
Block a user