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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ module.exports = tseslint.config(
|
|||||||
] }],
|
] }],
|
||||||
'@typescript-eslint/no-empty-function': 'off',
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
'@typescript-eslint/no-empty-interface': 'error',
|
'@typescript-eslint/no-empty-interface': 'error',
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
'@typescript-eslint/no-invalid-this': 'error',
|
'@typescript-eslint/no-invalid-this': 'error',
|
||||||
'@typescript-eslint/no-namespace': 'error',
|
'@typescript-eslint/no-namespace': 'error',
|
||||||
'@typescript-eslint/prefer-namespace-keyword': 'error',
|
'@typescript-eslint/prefer-namespace-keyword': 'error',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable complexity, @typescript-eslint/no-explicit-any */
|
/* eslint-disable complexity */
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
||||||
|
|
||||||
@@ -25,6 +25,28 @@ interface NormalizedKlipyGif {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KlipyGifVariants {
|
||||||
|
md?: unknown;
|
||||||
|
sm?: unknown;
|
||||||
|
xs?: unknown;
|
||||||
|
hd?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KlipyGifItem {
|
||||||
|
type?: unknown;
|
||||||
|
slug?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
file?: KlipyGifVariants;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KlipyApiResponse {
|
||||||
|
data?: {
|
||||||
|
data?: unknown;
|
||||||
|
has_next?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value != null)
|
if (value != null)
|
||||||
@@ -91,16 +113,38 @@ function pickGifMeta(sizeVariant: unknown): NormalizedMediaMeta | null {
|
|||||||
return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp);
|
return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeGifItem(item: any): NormalizedKlipyGif | null {
|
function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext: boolean } {
|
||||||
if (!item || typeof item !== 'object' || item.type === 'ad')
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
hasNext: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = payload as KlipyApiResponse;
|
||||||
|
const items = Array.isArray(response.data?.data) ? response.data.data : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
hasNext: response.data?.has_next === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
||||||
|
if (!item || typeof item !== 'object')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const lowVariant = pickFirst(item.file?.md, item.file?.sm, item.file?.xs, item.file?.hd);
|
const gifItem = item as KlipyGifItem;
|
||||||
const highVariant = pickFirst(item.file?.hd, item.file?.md, item.file?.sm, item.file?.xs);
|
|
||||||
|
if (gifItem.type === 'ad')
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd);
|
||||||
|
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
|
||||||
const lowMeta = pickGifMeta(lowVariant);
|
const lowMeta = pickGifMeta(lowVariant);
|
||||||
const highMeta = pickGifMeta(highVariant);
|
const highMeta = pickGifMeta(highVariant);
|
||||||
const selectedMeta = highMeta ?? lowMeta;
|
const selectedMeta = highMeta ?? lowMeta;
|
||||||
const slug = sanitizeString(item.slug) ?? sanitizeString(item.id);
|
const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
||||||
|
|
||||||
if (!slug || !selectedMeta?.url)
|
if (!slug || !selectedMeta?.url)
|
||||||
return null;
|
return null;
|
||||||
@@ -108,7 +152,7 @@ function normalizeGifItem(item: any): NormalizedKlipyGif | null {
|
|||||||
return {
|
return {
|
||||||
id: slug,
|
id: slug,
|
||||||
slug,
|
slug,
|
||||||
title: sanitizeString(item.title),
|
title: sanitizeString(gifItem.title),
|
||||||
url: selectedMeta.url,
|
url: selectedMeta.url,
|
||||||
previewUrl: lowMeta?.url ?? selectedMeta.url,
|
previewUrl: lowMeta?.url ?? selectedMeta.url,
|
||||||
width: selectedMeta.width ?? lowMeta?.width ?? 0,
|
width: selectedMeta.width ?? lowMeta?.width ?? 0,
|
||||||
@@ -196,9 +240,7 @@ router.get('/klipy/gifs', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawItems = Array.isArray((payload as any)?.data?.data)
|
const { items: rawItems, hasNext } = extractKlipyResponseData(payload);
|
||||||
? (payload as any).data.data
|
|
||||||
: [];
|
|
||||||
const results = rawItems
|
const results = rawItems
|
||||||
.map((item: unknown) => normalizeGifItem(item))
|
.map((item: unknown) => normalizeGifItem(item))
|
||||||
.filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item);
|
.filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item);
|
||||||
@@ -206,7 +248,7 @@ router.get('/klipy/gifs', async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
results,
|
results,
|
||||||
hasNext: (payload as any)?.data?.has_next === true
|
hasNext
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as { name?: string })?.name === 'AbortError') {
|
if ((error as { name?: string })?.name === 'AbortError') {
|
||||||
|
|||||||
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';
|
||||||
declare module 'sql.js' {
|
declare module 'sql.js' {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
|
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
export type Database = any;
|
export type Database = any;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
export type Statement = any;
|
export type Statement = any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { BanEntry, User } from '../models/index';
|
||||||
BanEntry,
|
|
||||||
User
|
|
||||||
} from '../models/index';
|
|
||||||
|
|
||||||
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;
|
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;
|
||||||
|
|
||||||
@@ -42,7 +39,7 @@ export function isRoomBanMatch(
|
|||||||
|
|
||||||
/** Return true when any active ban entry targets the provided user. */
|
/** Return true when any active ban entry targets the provided user. */
|
||||||
export function hasRoomBanForUser(
|
export function hasRoomBanForUser(
|
||||||
bans: Array<Pick<BanEntry, 'userId' | 'oderId'>>,
|
bans: Pick<BanEntry, 'userId' | 'oderId'>[],
|
||||||
user: BanAwareUser,
|
user: BanAwareUser,
|
||||||
persistedUserId?: string | null
|
persistedUserId?: string | null
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -178,6 +178,17 @@ export type ChatEventType =
|
|||||||
| 'room-settings-update'
|
| 'room-settings-update'
|
||||||
| 'voice-state'
|
| 'voice-state'
|
||||||
| 'chat-inventory-request'
|
| 'chat-inventory-request'
|
||||||
|
| 'chat-inventory'
|
||||||
|
| 'chat-sync-request-ids'
|
||||||
|
| 'chat-sync-batch'
|
||||||
|
| 'chat-sync-summary'
|
||||||
|
| 'chat-sync-request'
|
||||||
|
| 'chat-sync-full'
|
||||||
|
| 'file-announce'
|
||||||
|
| 'file-chunk'
|
||||||
|
| 'file-request'
|
||||||
|
| 'file-cancel'
|
||||||
|
| 'file-not-found'
|
||||||
| 'member-roster-request'
|
| 'member-roster-request'
|
||||||
| 'member-roster'
|
| 'member-roster'
|
||||||
| 'member-leave'
|
| 'member-leave'
|
||||||
@@ -197,6 +208,28 @@ export type ChatEventType =
|
|||||||
| 'unban'
|
| 'unban'
|
||||||
| 'channels-update';
|
| 'channels-update';
|
||||||
|
|
||||||
|
export interface ChatInventoryItem {
|
||||||
|
id: string;
|
||||||
|
ts: number;
|
||||||
|
rc: number;
|
||||||
|
ac?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAttachmentAnnouncement {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
mime: string;
|
||||||
|
isImage: boolean;
|
||||||
|
uploaderPeerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
|
||||||
|
messageId: string;
|
||||||
|
filePath?: string;
|
||||||
|
savedPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Optional fields depend on `type`. */
|
/** Optional fields depend on `type`. */
|
||||||
export interface ChatEvent {
|
export interface ChatEvent {
|
||||||
type: ChatEventType;
|
type: ChatEventType;
|
||||||
@@ -204,10 +237,20 @@ export interface ChatEvent {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
message?: Message;
|
message?: Message;
|
||||||
reaction?: Reaction;
|
reaction?: Reaction;
|
||||||
data?: Partial<Message>;
|
data?: string | Partial<Message>;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
|
items?: ChatInventoryItem[];
|
||||||
|
ids?: string[];
|
||||||
|
messages?: Message[];
|
||||||
|
attachments?: Record<string, ChatAttachmentMeta[]>;
|
||||||
|
total?: number;
|
||||||
|
index?: number;
|
||||||
|
count?: number;
|
||||||
|
lastUpdated?: number;
|
||||||
|
file?: ChatAttachmentAnnouncement;
|
||||||
|
fileId?: string;
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
hostOderId?: string;
|
hostOderId?: string;
|
||||||
previousHostId?: string;
|
previousHostId?: string;
|
||||||
@@ -226,6 +269,8 @@ export interface ChatEvent {
|
|||||||
permissions?: Partial<RoomPermissions>;
|
permissions?: Partial<RoomPermissions>;
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
isScreenSharing?: boolean;
|
isScreenSharing?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
iconUpdatedAt?: number;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
room?: Room;
|
room?: Room;
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
inject,
|
inject,
|
||||||
@@ -12,6 +12,11 @@ import { Store } from '@ngrx/store';
|
|||||||
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
||||||
import { DatabaseService } from './database.service';
|
import { DatabaseService } from './database.service';
|
||||||
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
|
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
|
||||||
|
import type {
|
||||||
|
ChatAttachmentAnnouncement,
|
||||||
|
ChatAttachmentMeta,
|
||||||
|
ChatEvent
|
||||||
|
} from '../models/index';
|
||||||
|
|
||||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
||||||
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
||||||
@@ -37,26 +42,7 @@ const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file
|
|||||||
/**
|
/**
|
||||||
* Metadata describing a file attachment linked to a chat message.
|
* Metadata describing a file attachment linked to a chat message.
|
||||||
*/
|
*/
|
||||||
export interface AttachmentMeta {
|
export type AttachmentMeta = ChatAttachmentMeta;
|
||||||
/** Unique attachment identifier. */
|
|
||||||
id: string;
|
|
||||||
/** ID of the parent message. */
|
|
||||||
messageId: string;
|
|
||||||
/** Original file name. */
|
|
||||||
filename: string;
|
|
||||||
/** File size in bytes. */
|
|
||||||
size: number;
|
|
||||||
/** MIME type (e.g. `image/png`). */
|
|
||||||
mime: string;
|
|
||||||
/** Whether the file is a raster/vector image. */
|
|
||||||
isImage: boolean;
|
|
||||||
/** Peer ID of the user who originally uploaded the file. */
|
|
||||||
uploaderPeerId?: string;
|
|
||||||
/** Electron-only: absolute path to the uploader's original file. */
|
|
||||||
filePath?: string;
|
|
||||||
/** Electron-only: disk-cache path where the file was saved locally. */
|
|
||||||
savedPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime representation of an attachment including download
|
* Runtime representation of an attachment including download
|
||||||
@@ -79,6 +65,72 @@ export interface Attachment extends AttachmentMeta {
|
|||||||
requestError?: string;
|
requestError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileAnnounceEvent = ChatEvent & {
|
||||||
|
type: 'file-announce';
|
||||||
|
messageId: string;
|
||||||
|
file: ChatAttachmentAnnouncement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileChunkEvent = ChatEvent & {
|
||||||
|
type: 'file-chunk';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
data: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileRequestEvent = ChatEvent & {
|
||||||
|
type: 'file-request';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileCancelEvent = ChatEvent & {
|
||||||
|
type: 'file-cancel';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileNotFoundEvent = ChatEvent & {
|
||||||
|
type: 'file-not-found';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
|
||||||
|
interface FileChunkPayload {
|
||||||
|
messageId?: string;
|
||||||
|
fileId?: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
index?: number;
|
||||||
|
total?: number;
|
||||||
|
data?: ChatEvent['data'];
|
||||||
|
}
|
||||||
|
type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||||
|
type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||||
|
type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
|
||||||
|
|
||||||
|
interface AttachmentElectronApi {
|
||||||
|
getAppDataPath?: () => Promise<string>;
|
||||||
|
fileExists?: (filePath: string) => Promise<boolean>;
|
||||||
|
readFile?: (filePath: string) => Promise<string>;
|
||||||
|
deleteFile?: (filePath: string) => Promise<boolean>;
|
||||||
|
ensureDir?: (dirPath: string) => Promise<boolean>;
|
||||||
|
writeFile?: (filePath: string, data: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElectronWindow = Window & {
|
||||||
|
electronAPI?: AttachmentElectronApi;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocalFileWithPath = File & {
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages peer-to-peer file transfer, local persistence, and
|
* Manages peer-to-peer file transfer, local persistence, and
|
||||||
* in-memory caching of file attachments linked to chat messages.
|
* in-memory caching of file attachments linked to chat messages.
|
||||||
@@ -140,6 +192,10 @@ export class AttachmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getElectronApi(): AttachmentElectronApi | undefined {
|
||||||
|
return (window as ElectronWindow).electronAPI;
|
||||||
|
}
|
||||||
|
|
||||||
/** Return the attachment list for a given message. */
|
/** Return the attachment list for a given message. */
|
||||||
getForMessage(messageId: string): Attachment[] {
|
getForMessage(messageId: string): Attachment[] {
|
||||||
return this.attachmentsByMessage.get(messageId) ?? [];
|
return this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
@@ -285,7 +341,7 @@ export class AttachmentService {
|
|||||||
/**
|
/**
|
||||||
* Handle a `file-not-found` response - try the next available peer.
|
* Handle a `file-not-found` response - try the next available peer.
|
||||||
*/
|
*/
|
||||||
handleFileNotFound(payload: any): void {
|
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||||
const { messageId, fileId } = payload;
|
const { messageId, fileId } = payload;
|
||||||
|
|
||||||
if (!messageId || !fileId)
|
if (!messageId || !fileId)
|
||||||
@@ -345,7 +401,7 @@ export class AttachmentService {
|
|||||||
mime: file.type || DEFAULT_MIME_TYPE,
|
mime: file.type || DEFAULT_MIME_TYPE,
|
||||||
isImage: file.type.startsWith('image/'),
|
isImage: file.type.startsWith('image/'),
|
||||||
uploaderPeerId,
|
uploaderPeerId,
|
||||||
filePath: (file as any)?.path,
|
filePath: (file as LocalFileWithPath).path,
|
||||||
available: false
|
available: false
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,7 +422,7 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast metadata to peers
|
// Broadcast metadata to peers
|
||||||
this.webrtc.broadcastMessage({
|
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||||
type: 'file-announce',
|
type: 'file-announce',
|
||||||
messageId,
|
messageId,
|
||||||
file: {
|
file: {
|
||||||
@@ -377,7 +433,9 @@ export class AttachmentService {
|
|||||||
isImage: attachment.isImage,
|
isImage: attachment.isImage,
|
||||||
uploaderPeerId
|
uploaderPeerId
|
||||||
}
|
}
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||||
|
|
||||||
// Auto-stream small inline-preview media
|
// Auto-stream small inline-preview media
|
||||||
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||||
@@ -396,7 +454,7 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Handle a `file-announce` event from a peer. */
|
/** Handle a `file-announce` event from a peer. */
|
||||||
handleFileAnnounce(payload: any): void {
|
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||||
const { messageId, file } = payload;
|
const { messageId, file } = payload;
|
||||||
|
|
||||||
if (!messageId || !file)
|
if (!messageId || !file)
|
||||||
@@ -433,14 +491,14 @@ export class AttachmentService {
|
|||||||
* expected count is reached, at which point the buffers are
|
* expected count is reached, at which point the buffers are
|
||||||
* assembled into a Blob and an object URL is created.
|
* assembled into a Blob and an object URL is created.
|
||||||
*/
|
*/
|
||||||
handleFileChunk(payload: any): void {
|
handleFileChunk(payload: FileChunkPayload): void {
|
||||||
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!messageId || !fileId ||
|
!messageId || !fileId ||
|
||||||
typeof index !== 'number' ||
|
typeof index !== 'number' ||
|
||||||
typeof total !== 'number' ||
|
typeof total !== 'number' ||
|
||||||
!data
|
typeof data !== 'string'
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -538,7 +596,7 @@ export class AttachmentService {
|
|||||||
* If none of these sources has the file, a `file-not-found`
|
* If none of these sources has the file, a `file-not-found`
|
||||||
* message is sent so the requester can try another peer.
|
* message is sent so the requester can try another peer.
|
||||||
*/
|
*/
|
||||||
async handleFileRequest(payload: any): Promise<void> {
|
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||||
const { messageId, fileId, fromPeerId } = payload;
|
const { messageId, fileId, fromPeerId } = payload;
|
||||||
|
|
||||||
if (!messageId || !fileId || !fromPeerId)
|
if (!messageId || !fileId || !fromPeerId)
|
||||||
@@ -566,7 +624,7 @@ export class AttachmentService {
|
|||||||
|
|
||||||
const list = this.attachmentsByMessage.get(messageId) ?? [];
|
const list = this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
const attachment = list.find((entry) => entry.id === fileId);
|
const attachment = list.find((entry) => entry.id === fileId);
|
||||||
const electronApi = (window as any)?.electronAPI;
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
// 2. Electron filePath
|
// 2. Electron filePath
|
||||||
if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) {
|
if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) {
|
||||||
@@ -619,11 +677,13 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. File not available locally
|
// 5. File not available locally
|
||||||
this.webrtc.sendToPeer(fromPeerId, {
|
const fileNotFoundEvent: FileNotFoundEvent = {
|
||||||
type: 'file-not-found',
|
type: 'file-not-found',
|
||||||
messageId,
|
messageId,
|
||||||
fileId
|
fileId
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -658,11 +718,13 @@ export class AttachmentService {
|
|||||||
this.touch();
|
this.touch();
|
||||||
|
|
||||||
// Notify uploader to stop streaming
|
// Notify uploader to stop streaming
|
||||||
this.webrtc.sendToPeer(targetPeerId, {
|
const fileCancelEvent: FileCancelEvent = {
|
||||||
type: 'file-cancel',
|
type: 'file-cancel',
|
||||||
messageId,
|
messageId,
|
||||||
fileId: attachment.id
|
fileId: attachment.id
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
||||||
} catch { /* best-effort */ }
|
} catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +732,7 @@ export class AttachmentService {
|
|||||||
* Handle a `file-cancel` from the requester - record the
|
* Handle a `file-cancel` from the requester - record the
|
||||||
* cancellation so the streaming loop breaks early.
|
* cancellation so the streaming loop breaks early.
|
||||||
*/
|
*/
|
||||||
handleFileCancel(payload: any): void {
|
handleFileCancel(payload: FileCancelPayload): void {
|
||||||
const { messageId, fileId, fromPeerId } = payload;
|
const { messageId, fileId, fromPeerId } = payload;
|
||||||
|
|
||||||
if (!messageId || !fileId || !fromPeerId)
|
if (!messageId || !fileId || !fromPeerId)
|
||||||
@@ -774,7 +836,7 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async deleteSavedFile(filePath: string): Promise<void> {
|
private async deleteSavedFile(filePath: string): Promise<void> {
|
||||||
const electronApi = (window as any)?.electronAPI;
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
if (!electronApi?.deleteFile)
|
if (!electronApi?.deleteFile)
|
||||||
return;
|
return;
|
||||||
@@ -842,11 +904,13 @@ export class AttachmentService {
|
|||||||
triedPeers.add(targetPeerId);
|
triedPeers.add(targetPeerId);
|
||||||
this.pendingRequests.set(requestKey, triedPeers);
|
this.pendingRequests.set(requestKey, triedPeers);
|
||||||
|
|
||||||
this.webrtc.sendToPeer(targetPeerId, {
|
const fileRequestEvent: FileRequestEvent = {
|
||||||
type: 'file-request',
|
type: 'file-request',
|
||||||
messageId,
|
messageId,
|
||||||
fileId
|
fileId
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(targetPeerId, fileRequestEvent);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -866,15 +930,16 @@ export class AttachmentService {
|
|||||||
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
||||||
const arrayBuffer = await slice.arrayBuffer();
|
const arrayBuffer = await slice.arrayBuffer();
|
||||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||||
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
messageId,
|
messageId,
|
||||||
fileId,
|
fileId,
|
||||||
index: chunkIndex,
|
index: chunkIndex,
|
||||||
total: totalChunks,
|
total: totalChunks,
|
||||||
data: base64
|
data: base64
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
this.webrtc.broadcastMessage(fileChunkEvent);
|
||||||
|
|
||||||
offset += FILE_CHUNK_SIZE_BYTES;
|
offset += FILE_CHUNK_SIZE_BYTES;
|
||||||
chunkIndex++;
|
chunkIndex++;
|
||||||
@@ -900,15 +965,16 @@ export class AttachmentService {
|
|||||||
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
||||||
const arrayBuffer = await slice.arrayBuffer();
|
const arrayBuffer = await slice.arrayBuffer();
|
||||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||||
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
await this.webrtc.sendToPeerBuffered(targetPeerId, {
|
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
messageId,
|
messageId,
|
||||||
fileId,
|
fileId,
|
||||||
index: chunkIndex,
|
index: chunkIndex,
|
||||||
total: totalChunks,
|
total: totalChunks,
|
||||||
data: base64
|
data: base64
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||||
|
|
||||||
offset += FILE_CHUNK_SIZE_BYTES;
|
offset += FILE_CHUNK_SIZE_BYTES;
|
||||||
chunkIndex++;
|
chunkIndex++;
|
||||||
@@ -925,7 +991,11 @@ export class AttachmentService {
|
|||||||
fileId: string,
|
fileId: string,
|
||||||
diskPath: string
|
diskPath: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const electronApi = (window as any)?.electronAPI;
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!electronApi?.readFile)
|
||||||
|
return;
|
||||||
|
|
||||||
const base64Full = await electronApi.readFile(diskPath);
|
const base64Full = await electronApi.readFile(diskPath);
|
||||||
const fileBytes = this.base64ToUint8Array(base64Full);
|
const fileBytes = this.base64ToUint8Array(base64Full);
|
||||||
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||||
@@ -942,15 +1012,16 @@ export class AttachmentService {
|
|||||||
slice.byteOffset + slice.byteLength
|
slice.byteOffset + slice.byteLength
|
||||||
);
|
);
|
||||||
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
||||||
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
this.webrtc.sendToPeer(targetPeerId, {
|
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
messageId,
|
messageId,
|
||||||
fileId,
|
fileId,
|
||||||
index: chunkIndex,
|
index: chunkIndex,
|
||||||
total: totalChunks,
|
total: totalChunks,
|
||||||
data: base64Chunk
|
data: base64Chunk
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,10 +1031,10 @@ export class AttachmentService {
|
|||||||
*/
|
*/
|
||||||
private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
|
private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const electronApi = (window as any)?.electronAPI;
|
const electronApi = this.getElectronApi();
|
||||||
const appDataPath: string | undefined = await electronApi?.getAppDataPath?.();
|
const appDataPath: string | undefined = await electronApi?.getAppDataPath?.();
|
||||||
|
|
||||||
if (!appDataPath)
|
if (!appDataPath || !electronApi?.ensureDir || !electronApi.writeFile)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const roomName = await this.resolveCurrentRoomName();
|
const roomName = await this.resolveCurrentRoomName();
|
||||||
@@ -992,7 +1063,7 @@ export class AttachmentService {
|
|||||||
|
|
||||||
/** On startup, try loading previously saved files from disk (Electron). */
|
/** On startup, try loading previously saved files from disk (Electron). */
|
||||||
private async tryLoadSavedFiles(): Promise<void> {
|
private async tryLoadSavedFiles(): Promise<void> {
|
||||||
const electronApi = (window as any)?.electronAPI;
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
if (!electronApi?.fileExists || !electronApi?.readFile)
|
if (!electronApi?.fileExists || !electronApi?.readFile)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable, @typescript-eslint/no-non-null-assertion */
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
DELETED_MESSAGE_CONTENT,
|
DELETED_MESSAGE_CONTENT,
|
||||||
|
ChatAttachmentMeta,
|
||||||
Message,
|
Message,
|
||||||
User,
|
User,
|
||||||
Room,
|
Room,
|
||||||
@@ -234,10 +235,7 @@ export class BrowserDatabaseService {
|
|||||||
const match = allBans.find((ban) => ban.oderId === oderId);
|
const match = allBans.find((ban) => ban.oderId === oderId);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
await this.deleteRecord(
|
await this.deleteRecord(STORE_BANS, match.oderId);
|
||||||
STORE_BANS,
|
|
||||||
(match as any).id ?? match.oderId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,23 +263,23 @@ export class BrowserDatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Persist an attachment metadata record. */
|
/** Persist an attachment metadata record. */
|
||||||
async saveAttachment(attachment: any): Promise<void> {
|
async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
|
||||||
await this.put(STORE_ATTACHMENTS, attachment);
|
await this.put(STORE_ATTACHMENTS, attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return all attachment records for a message. */
|
/** Return all attachment records for a message. */
|
||||||
async getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
|
||||||
return this.getAllFromIndex<any>(STORE_ATTACHMENTS, 'messageId', messageId);
|
return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return every persisted attachment record. */
|
/** Return every persisted attachment record. */
|
||||||
async getAllAttachments(): Promise<any[]> {
|
async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
|
||||||
return this.getAll<any>(STORE_ATTACHMENTS);
|
return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete all attachment records for a message. */
|
/** Delete all attachment records for a message. */
|
||||||
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||||
const attachments = await this.getAllFromIndex<any>(
|
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
|
||||||
STORE_ATTACHMENTS, 'messageId', messageId
|
STORE_ATTACHMENTS, 'messageId', messageId
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -308,9 +306,7 @@ export class BrowserDatabaseService {
|
|||||||
await this.awaitTransaction(transaction);
|
await this.awaitTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════
|
|
||||||
// Private helpers - thin wrappers around IndexedDB
|
// Private helpers - thin wrappers around IndexedDB
|
||||||
// ══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open (or upgrade) the IndexedDB database and create any missing
|
* Open (or upgrade) the IndexedDB database and create any missing
|
||||||
@@ -370,7 +366,15 @@ export class BrowserDatabaseService {
|
|||||||
stores: string | string[],
|
stores: string | string[],
|
||||||
mode: IDBTransactionMode = 'readonly'
|
mode: IDBTransactionMode = 'readonly'
|
||||||
): IDBTransaction {
|
): IDBTransaction {
|
||||||
return this.database!.transaction(stores, mode);
|
return this.getDatabase().transaction(stores, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDatabase(): IDBDatabase {
|
||||||
|
if (!this.database) {
|
||||||
|
throw new Error('Browser database is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.database;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap a transaction's completion event as a Promise. */
|
/** Wrap a transaction's completion event as a Promise. */
|
||||||
@@ -420,7 +424,7 @@ export class BrowserDatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Insert or update a record in the given object store. */
|
/** Insert or update a record in the given object store. */
|
||||||
private put(storeName: string, value: any): Promise<void> {
|
private put<T>(storeName: string, value: T): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||||
import {
|
import {
|
||||||
inject,
|
inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
Room,
|
Room,
|
||||||
Reaction,
|
Reaction,
|
||||||
BanEntry
|
BanEntry,
|
||||||
|
ChatAttachmentMeta
|
||||||
} from '../models/index';
|
} from '../models/index';
|
||||||
import { PlatformService } from './platform.service';
|
import { PlatformService } from './platform.service';
|
||||||
import { BrowserDatabaseService } from './browser-database.service';
|
import { BrowserDatabaseService } from './browser-database.service';
|
||||||
@@ -118,7 +119,7 @@ export class DatabaseService {
|
|||||||
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
|
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
|
||||||
|
|
||||||
/** Persist attachment metadata. */
|
/** Persist attachment metadata. */
|
||||||
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); }
|
saveAttachment(attachment: ChatAttachmentMeta) { return this.backend.saveAttachment(attachment); }
|
||||||
|
|
||||||
/** Return all attachment records for a message. */
|
/** Return all attachment records for a message. */
|
||||||
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface ElectronAPI {
|
|||||||
export class ElectronDatabaseService {
|
export class ElectronDatabaseService {
|
||||||
/** Shorthand accessor for the preload-exposed CQRS API. */
|
/** Shorthand accessor for the preload-exposed CQRS API. */
|
||||||
private get api(): ElectronAPI {
|
private get api(): ElectronAPI {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
return (window as any).electronAPI as ElectronAPI;
|
return (window as any).electronAPI as ElectronAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,22 +165,22 @@ export class ElectronDatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Persist attachment metadata. */
|
/** Persist attachment metadata. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
saveAttachment(attachment: any): Promise<void> {
|
saveAttachment(attachment: any): Promise<void> {
|
||||||
return this.api.command({ type: 'save-attachment', payload: { attachment } });
|
return this.api.command({ type: 'save-attachment', payload: { attachment } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return all attachment records for a message. */
|
/** Return all attachment records for a message. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
return this.api.query<any[]>({ type: 'get-attachments-for-message', payload: { messageId } });
|
return this.api.query<any[]>({ type: 'get-attachments-for-message', payload: { messageId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return every persisted attachment record. */
|
/** Return every persisted attachment record. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
getAllAttachments(): Promise<any[]> {
|
getAllAttachments(): Promise<any[]> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} });
|
return this.api.query<any[]>({ type: 'get-all-attachments', payload: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { PlatformService } from './platform.service';
|
import { PlatformService } from './platform.service';
|
||||||
|
|
||||||
|
interface ExternalLinkElectronApi {
|
||||||
|
openExternal?: (url: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalLinkWindow = Window & {
|
||||||
|
electronAPI?: ExternalLinkElectronApi;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
||||||
*
|
*
|
||||||
@@ -17,7 +25,7 @@ export class ExternalLinkService {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
if (this.platform.isElectron) {
|
if (this.platform.isElectron) {
|
||||||
(window as any).electronAPI?.openExternal(url);
|
(window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
|
||||||
} else {
|
} else {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
type ElectronPlatformWindow = Window & {
|
||||||
|
electronAPI?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlatformService {
|
export class PlatformService {
|
||||||
readonly isElectron: boolean;
|
readonly isElectron: boolean;
|
||||||
@@ -7,7 +11,7 @@ export class PlatformService {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.isElectron =
|
this.isElectron =
|
||||||
typeof window !== 'undefined' && !!(window as any).electronAPI;
|
typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI;
|
||||||
|
|
||||||
this.isBrowser = !this.isElectron;
|
this.isBrowser = !this.isElectron;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
signal,
|
signal,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
import { Room } from '../models';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,19 +123,21 @@ export class VoiceSessionService {
|
|||||||
if (!session)
|
if (!session)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const room: Room = {
|
||||||
|
id: session.serverId,
|
||||||
|
name: session.serverName,
|
||||||
|
description: session.serverDescription,
|
||||||
|
hostId: '',
|
||||||
|
isPrivate: false,
|
||||||
|
createdAt: 0,
|
||||||
|
userCount: 0,
|
||||||
|
maxUsers: 50,
|
||||||
|
icon: session.serverIcon
|
||||||
|
};
|
||||||
|
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
RoomsActions.viewServer({
|
RoomsActions.viewServer({
|
||||||
room: {
|
room
|
||||||
id: session.serverId,
|
|
||||||
name: session.serverName,
|
|
||||||
description: session.serverDescription,
|
|
||||||
hostId: '',
|
|
||||||
isPrivate: false,
|
|
||||||
createdAt: 0,
|
|
||||||
userCount: 0,
|
|
||||||
maxUsers: 50,
|
|
||||||
icon: session.serverIcon
|
|
||||||
} as any
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* This file wires them together and exposes a public API that is
|
* This file wires them together and exposes a public API that is
|
||||||
* identical to the old monolithic service so consumers don't change.
|
* identical to the old monolithic service so consumers don't change.
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars */
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
signal,
|
signal,
|
||||||
@@ -54,6 +54,27 @@ import {
|
|||||||
P2P_TYPE_SCREEN_STATE
|
P2P_TYPE_SCREEN_STATE
|
||||||
} from './webrtc';
|
} from './webrtc';
|
||||||
|
|
||||||
|
interface SignalingUserSummary {
|
||||||
|
oderId: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncomingSignalingPayload {
|
||||||
|
sdp?: RTCSessionDescriptionInit;
|
||||||
|
candidate?: RTCIceCandidateInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> & {
|
||||||
|
type: string;
|
||||||
|
payload?: IncomingSignalingPayload;
|
||||||
|
oderId?: string;
|
||||||
|
serverTime?: number;
|
||||||
|
serverId?: string;
|
||||||
|
users?: SignalingUserSummary[];
|
||||||
|
displayName?: string;
|
||||||
|
fromUserId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -120,7 +141,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
/** Per-peer latency map (ms). Read via `peerLatencies()`. */
|
/** Per-peer latency map (ms). Read via `peerLatencies()`. */
|
||||||
readonly peerLatencies = computed(() => this._peerLatencies());
|
readonly peerLatencies = computed(() => this._peerLatencies());
|
||||||
|
|
||||||
private readonly signalingMessage$ = new Subject<SignalingMessage>();
|
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
|
||||||
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
||||||
|
|
||||||
// Delegates to managers
|
// Delegates to managers
|
||||||
@@ -175,7 +196,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
|
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
|
||||||
this.peerManager.activePeerConnections,
|
this.peerManager.activePeerConnections,
|
||||||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||||||
broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event),
|
broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event),
|
||||||
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
|
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
|
||||||
getIdentifyDisplayName: (): string =>
|
getIdentifyDisplayName: (): string =>
|
||||||
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
|
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
|
||||||
@@ -254,110 +275,145 @@ export class WebRTCService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSignalingMessage(message: any): void {
|
private handleSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
this.signalingMessage$.next(message);
|
this.signalingMessage$.next(message);
|
||||||
this.logger.info('Signaling message', { type: message.type });
|
this.logger.info('Signaling message', { type: message.type });
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case SIGNALING_TYPE_CONNECTED:
|
case SIGNALING_TYPE_CONNECTED:
|
||||||
this.logger.info('Server connected', { oderId: message.oderId });
|
this.handleConnectedSignalingMessage(message);
|
||||||
|
return;
|
||||||
|
|
||||||
if (typeof message.serverTime === 'number') {
|
case SIGNALING_TYPE_SERVER_USERS:
|
||||||
this.timeSync.setFromServerTime(message.serverTime);
|
this.handleServerUsersSignalingMessage(message);
|
||||||
}
|
return;
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SIGNALING_TYPE_SERVER_USERS: {
|
|
||||||
this.logger.info('Server users', {
|
|
||||||
count: Array.isArray(message.users) ? message.users.length : 0,
|
|
||||||
serverId: message.serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (message.users && Array.isArray(message.users)) {
|
|
||||||
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
|
||||||
if (!user.oderId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
|
||||||
const healthy = this.isPeerHealthy(existing);
|
|
||||||
|
|
||||||
if (existing && !healthy) {
|
|
||||||
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
|
||||||
this.peerManager.removePeer(user.oderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!healthy) {
|
|
||||||
this.logger.info('Create peer connection to existing user', {
|
|
||||||
oderId: user.oderId,
|
|
||||||
serverId: message.serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
this.peerManager.createPeerConnection(user.oderId, true);
|
|
||||||
this.peerManager.createAndSendOffer(user.oderId);
|
|
||||||
|
|
||||||
if (message.serverId) {
|
|
||||||
this.peerServerMap.set(user.oderId, message.serverId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_JOINED:
|
case SIGNALING_TYPE_USER_JOINED:
|
||||||
this.logger.info('User joined', {
|
this.handleUserJoinedSignalingMessage(message);
|
||||||
displayName: message.displayName,
|
return;
|
||||||
oderId: message.oderId
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_LEFT:
|
case SIGNALING_TYPE_USER_LEFT:
|
||||||
this.logger.info('User left', {
|
this.handleUserLeftSignalingMessage(message);
|
||||||
displayName: message.displayName,
|
return;
|
||||||
oderId: message.oderId,
|
|
||||||
serverId: message.serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (message.oderId) {
|
|
||||||
this.peerManager.removePeer(message.oderId);
|
|
||||||
this.peerServerMap.delete(message.oderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SIGNALING_TYPE_OFFER:
|
case SIGNALING_TYPE_OFFER:
|
||||||
if (message.fromUserId && message.payload?.sdp) {
|
this.handleOfferSignalingMessage(message);
|
||||||
// Track inbound peer as belonging to our effective server
|
return;
|
||||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
|
||||||
|
|
||||||
if (offerEffectiveServer && !this.peerServerMap.has(message.fromUserId)) {
|
|
||||||
this.peerServerMap.set(message.fromUserId, offerEffectiveServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SIGNALING_TYPE_ANSWER:
|
case SIGNALING_TYPE_ANSWER:
|
||||||
if (message.fromUserId && message.payload?.sdp) {
|
this.handleAnswerSignalingMessage(message);
|
||||||
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp);
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||||
if (message.fromUserId && message.payload?.candidate) {
|
this.handleIceCandidateSignalingMessage(message);
|
||||||
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate);
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
default:
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
this.logger.info('Server connected', { oderId: message.oderId });
|
||||||
|
|
||||||
|
if (typeof message.serverTime === 'number') {
|
||||||
|
this.timeSync.setFromServerTime(message.serverTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
const users = Array.isArray(message.users) ? message.users : [];
|
||||||
|
|
||||||
|
this.logger.info('Server users', {
|
||||||
|
count: users.length,
|
||||||
|
serverId: message.serverId
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (!user.oderId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||||
|
const healthy = this.isPeerHealthy(existing);
|
||||||
|
|
||||||
|
if (existing && !healthy) {
|
||||||
|
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
||||||
|
this.peerManager.removePeer(user.oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthy)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
this.logger.info('Create peer connection to existing user', {
|
||||||
|
oderId: user.oderId,
|
||||||
|
serverId: message.serverId
|
||||||
|
});
|
||||||
|
|
||||||
|
this.peerManager.createPeerConnection(user.oderId, true);
|
||||||
|
this.peerManager.createAndSendOffer(user.oderId);
|
||||||
|
|
||||||
|
if (message.serverId) {
|
||||||
|
this.peerServerMap.set(user.oderId, message.serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
this.logger.info('User joined', {
|
||||||
|
displayName: message.displayName,
|
||||||
|
oderId: message.oderId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
this.logger.info('User left', {
|
||||||
|
displayName: message.displayName,
|
||||||
|
oderId: message.oderId,
|
||||||
|
serverId: message.serverId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.oderId) {
|
||||||
|
this.peerManager.removePeer(message.oderId);
|
||||||
|
this.peerServerMap.delete(message.oderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
const fromUserId = message.fromUserId;
|
||||||
|
const sdp = message.payload?.sdp;
|
||||||
|
|
||||||
|
if (!fromUserId || !sdp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||||
|
|
||||||
|
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
||||||
|
this.peerServerMap.set(fromUserId, offerEffectiveServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peerManager.handleOffer(fromUserId, sdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
const fromUserId = message.fromUserId;
|
||||||
|
const sdp = message.payload?.sdp;
|
||||||
|
|
||||||
|
if (!fromUserId || !sdp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.peerManager.handleAnswer(fromUserId, sdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
const fromUserId = message.fromUserId;
|
||||||
|
const candidate = message.payload?.candidate;
|
||||||
|
|
||||||
|
if (!fromUserId || !candidate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close all peer connections that were discovered from a server
|
* Close all peer connections that were discovered from a server
|
||||||
* other than `serverId`. Also removes their entries from
|
* other than `serverId`. Also removes their entries from
|
||||||
@@ -395,9 +451,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
|
||||||
// PUBLIC API - matches the old monolithic service's interface
|
// PUBLIC API - matches the old monolithic service's interface
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to a signaling server via WebSocket.
|
* Connect to a signaling server via WebSocket.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, id-length */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars,, id-length */
|
||||||
/**
|
/**
|
||||||
* Manages local voice media: getUserMedia, mute, deafen,
|
* Manages local voice media: getUserMedia, mute, deafen,
|
||||||
* attaching/detaching audio tracks to peer connections, bitrate tuning,
|
* attaching/detaching audio tracks to peer connections, bitrate tuning,
|
||||||
* and optional RNNoise-based noise reduction.
|
* and optional RNNoise-based noise reduction.
|
||||||
*/
|
*/
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
import { ChatEvent } from '../../models';
|
||||||
import { WebRTCLogger } from './webrtc-logger';
|
import { WebRTCLogger } from './webrtc-logger';
|
||||||
import { PeerData } from './webrtc.types';
|
import { PeerData } from './webrtc.types';
|
||||||
import { NoiseReductionManager } from './noise-reduction.manager';
|
import { NoiseReductionManager } from './noise-reduction.manager';
|
||||||
@@ -35,7 +36,7 @@ export interface MediaManagerCallbacks {
|
|||||||
/** Trigger SDP renegotiation for a specific peer. */
|
/** Trigger SDP renegotiation for a specific peer. */
|
||||||
renegotiate(peerId: string): Promise<void>;
|
renegotiate(peerId: string): Promise<void>;
|
||||||
/** Broadcast a message to all peers. */
|
/** Broadcast a message to all peers. */
|
||||||
broadcastMessage(event: any): void;
|
broadcastMessage(event: ChatEvent): void;
|
||||||
/** Get identify credentials (for broadcasting). */
|
/** Get identify credentials (for broadcasting). */
|
||||||
getIdentifyOderId(): string;
|
getIdentifyOderId(): string;
|
||||||
getIdentifyDisplayName(): string;
|
getIdentifyDisplayName(): string;
|
||||||
@@ -410,7 +411,7 @@ export class MediaManager {
|
|||||||
try {
|
try {
|
||||||
params = sender.getParameters();
|
params = sender.getParameters();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('getParameters failed; skipping bitrate apply', error as any);
|
this.logger.warn('getParameters failed; skipping bitrate apply', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +422,7 @@ export class MediaManager {
|
|||||||
await sender.setParameters(params);
|
await sender.setParameters(params);
|
||||||
this.logger.info('Applied audio bitrate', { targetBps });
|
this.logger.info('Applied audio bitrate', { targetBps });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('Failed to set audio bitrate', error as any);
|
this.logger.warn('Failed to set audio bitrate', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -632,12 +633,12 @@ export class MediaManager {
|
|||||||
this.inputGainSourceNode?.disconnect();
|
this.inputGainSourceNode?.disconnect();
|
||||||
this.inputGainNode?.disconnect();
|
this.inputGainNode?.disconnect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('Input gain nodes were already disconnected during teardown', error as any);
|
this.logger.warn('Input gain nodes were already disconnected during teardown', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') {
|
if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') {
|
||||||
this.inputGainCtx.close().catch((error) => {
|
this.inputGainCtx.close().catch((error) => {
|
||||||
this.logger.warn('Failed to close input gain audio context', error as any);
|
this.logger.warn('Failed to close input gain audio context', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO } from '../../webrtc.constants';
|
||||||
TRACK_KIND_AUDIO,
|
|
||||||
TRACK_KIND_VIDEO
|
|
||||||
} from '../../webrtc.constants';
|
|
||||||
import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service';
|
import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service';
|
||||||
import { PeerConnectionManagerContext } from '../shared';
|
import { PeerConnectionManagerContext } from '../shared';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */
|
/* eslint-disable, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */
|
||||||
/**
|
/**
|
||||||
* Manages screen sharing: getDisplayMedia / Electron desktop capturer,
|
* Manages screen sharing: getDisplayMedia / Electron desktop capturer,
|
||||||
* system-audio capture, and attaching screen tracks to peers.
|
* system-audio capture, and attaching screen tracks to peers.
|
||||||
@@ -71,6 +71,49 @@ interface LinuxScreenShareMonitorAudioPipeline {
|
|||||||
unsubscribeEnded: () => void;
|
unsubscribeEnded: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DesktopSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenShareElectronApi {
|
||||||
|
getSources?: () => Promise<DesktopSource[]>;
|
||||||
|
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
|
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
|
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
|
||||||
|
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
||||||
|
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
|
||||||
|
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
|
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop';
|
||||||
|
chromeMediaSourceId: string;
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
maxFrameRate: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop';
|
||||||
|
chromeMediaSourceId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
|
||||||
|
video: ElectronDesktopVideoConstraint;
|
||||||
|
audio?: false | ElectronDesktopAudioConstraint;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreenShareWindow = Window & {
|
||||||
|
electronAPI?: ScreenShareElectronApi;
|
||||||
|
};
|
||||||
|
|
||||||
export class ScreenShareManager {
|
export class ScreenShareManager {
|
||||||
/** The active screen-capture stream. */
|
/** The active screen-capture stream. */
|
||||||
private activeScreenStream: MediaStream | null = null;
|
private activeScreenStream: MediaStream | null = null;
|
||||||
@@ -105,10 +148,10 @@ export class ScreenShareManager {
|
|||||||
* Replace the callback set at runtime.
|
* Replace the callback set at runtime.
|
||||||
* Needed because of circular initialisation between managers.
|
* Needed because of circular initialisation between managers.
|
||||||
*
|
*
|
||||||
* @param cb - The new callback interface to wire into this manager.
|
* @param nextCallbacks - The new callback interface to wire into this manager.
|
||||||
*/
|
*/
|
||||||
setCallbacks(cb: ScreenShareCallbacks): void {
|
setCallbacks(nextCallbacks: ScreenShareCallbacks): void {
|
||||||
this.callbacks = cb;
|
this.callbacks = nextCallbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the current screen-capture stream, or `null` if inactive. */
|
/** Returns the current screen-capture stream, or `null` if inactive. */
|
||||||
@@ -151,7 +194,7 @@ export class ScreenShareManager {
|
|||||||
try {
|
try {
|
||||||
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
|
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error as any);
|
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,15 +208,15 @@ export class ScreenShareManager {
|
|||||||
this.activeScreenStream = null;
|
this.activeScreenStream = null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error as any);
|
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.activeScreenStream && typeof window !== 'undefined' && (window as any).electronAPI?.getSources) {
|
if (!this.activeScreenStream && this.getElectronApi()?.getSources) {
|
||||||
try {
|
try {
|
||||||
this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset);
|
this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error as any);
|
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +232,13 @@ export class ScreenShareManager {
|
|||||||
this.isScreenActive = true;
|
this.isScreenActive = true;
|
||||||
this.callbacks.broadcastCurrentStates();
|
this.callbacks.broadcastCurrentStates();
|
||||||
|
|
||||||
const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0];
|
const activeScreenStream = this.activeScreenStream;
|
||||||
|
|
||||||
|
if (!activeScreenStream) {
|
||||||
|
throw new Error('Screen sharing did not produce an active stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenVideoTrack = activeScreenStream.getVideoTracks()[0];
|
||||||
|
|
||||||
if (screenVideoTrack) {
|
if (screenVideoTrack) {
|
||||||
screenVideoTrack.onended = () => {
|
screenVideoTrack.onended = () => {
|
||||||
@@ -198,7 +247,7 @@ export class ScreenShareManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.activeScreenStream!;
|
return activeScreenStream;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to start screen share', error);
|
this.logger.error('Failed to start screen share', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -287,6 +336,63 @@ export class ScreenShareManager {
|
|||||||
this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset);
|
this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clean up all resources. */
|
||||||
|
destroy(): void {
|
||||||
|
this.stopScreenShare();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getElectronApi(): ScreenShareElectronApi | null {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
? (window as ScreenShareWindow).electronAPI ?? null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRequiredLinuxElectronApi(): Required<Pick<
|
||||||
|
ScreenShareElectronApi,
|
||||||
|
| 'prepareLinuxScreenShareAudioRouting'
|
||||||
|
| 'activateLinuxScreenShareAudioRouting'
|
||||||
|
| 'deactivateLinuxScreenShareAudioRouting'
|
||||||
|
| 'startLinuxScreenShareMonitorCapture'
|
||||||
|
| 'stopLinuxScreenShareMonitorCapture'
|
||||||
|
| 'onLinuxScreenShareMonitorAudioChunk'
|
||||||
|
| 'onLinuxScreenShareMonitorAudioEnded'
|
||||||
|
>> {
|
||||||
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|
||||||
|
|| !electronApi.activateLinuxScreenShareAudioRouting
|
||||||
|
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|
||||||
|
|| !electronApi.startLinuxScreenShareMonitorCapture
|
||||||
|
|| !electronApi.stopLinuxScreenShareMonitorCapture
|
||||||
|
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|
||||||
|
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
|
||||||
|
throw new Error('Linux Electron audio routing is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
|
||||||
|
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
|
||||||
|
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
|
||||||
|
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
|
||||||
|
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
|
||||||
|
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
|
||||||
|
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertLinuxAudioRoutingReady(
|
||||||
|
routingInfo: LinuxScreenShareAudioRoutingInfo,
|
||||||
|
unavailableReason: string
|
||||||
|
): void {
|
||||||
|
if (!routingInfo.available) {
|
||||||
|
throw new Error(routingInfo.reason || unavailableReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!routingInfo.monitorCaptureSupported) {
|
||||||
|
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a dedicated stream for system audio captured alongside the screen.
|
* Create a dedicated stream for system audio captured alongside the screen.
|
||||||
*
|
*
|
||||||
@@ -450,15 +556,26 @@ export class ScreenShareManager {
|
|||||||
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
|
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints);
|
return await navigator.mediaDevices.getDisplayMedia(displayConstraints);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startWithElectronDesktopCapturer(
|
private async startWithElectronDesktopCapturer(
|
||||||
options: ScreenShareStartOptions,
|
options: ScreenShareStartOptions,
|
||||||
preset: ScreenShareQualityPreset
|
preset: ScreenShareQualityPreset
|
||||||
): Promise<MediaStream> {
|
): Promise<MediaStream> {
|
||||||
const sources = await (window as any).electronAPI.getSources();
|
const electronApi = this.getElectronApi();
|
||||||
const screenSource = sources.find((source: any) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
|
|
||||||
|
if (!electronApi?.getSources) {
|
||||||
|
throw new Error('Electron desktop capture is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = await electronApi.getSources();
|
||||||
|
const screenSource = sources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) ?? sources[0];
|
||||||
|
|
||||||
|
if (!screenSource) {
|
||||||
|
throw new Error('No desktop capture sources were available.');
|
||||||
|
}
|
||||||
|
|
||||||
const electronConstraints = this.buildElectronDesktopConstraints(screenSource.id, options, preset);
|
const electronConstraints = this.buildElectronDesktopConstraints(screenSource.id, options, preset);
|
||||||
|
|
||||||
this.logger.info('desktopCapturer constraints', electronConstraints);
|
this.logger.info('desktopCapturer constraints', electronConstraints);
|
||||||
@@ -475,7 +592,7 @@ export class ScreenShareManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const electronApi = (window as any).electronAPI;
|
const electronApi = this.getElectronApi();
|
||||||
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
|
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
|
||||||
|
|
||||||
return !!electronApi?.prepareLinuxScreenShareAudioRouting
|
return !!electronApi?.prepareLinuxScreenShareAudioRouting
|
||||||
@@ -492,28 +609,20 @@ export class ScreenShareManager {
|
|||||||
options: ScreenShareStartOptions,
|
options: ScreenShareStartOptions,
|
||||||
preset: ScreenShareQualityPreset
|
preset: ScreenShareQualityPreset
|
||||||
): Promise<MediaStream> {
|
): Promise<MediaStream> {
|
||||||
const electronApi = (window as any).electronAPI;
|
const electronApi = this.getRequiredLinuxElectronApi();
|
||||||
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting() as LinuxScreenShareAudioRoutingInfo;
|
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
|
||||||
|
|
||||||
if (!routingInfo?.available) {
|
this.assertLinuxAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
|
||||||
throw new Error(routingInfo?.reason || 'Linux Electron audio routing is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!routingInfo.monitorCaptureSupported) {
|
|
||||||
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let desktopStream: MediaStream | null = null;
|
let desktopStream: MediaStream | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activation = await electronApi.activateLinuxScreenShareAudioRouting() as LinuxScreenShareAudioRoutingInfo;
|
const activation = await electronApi.activateLinuxScreenShareAudioRouting();
|
||||||
|
|
||||||
if (!activation?.available || !activation.active) {
|
this.assertLinuxAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
|
||||||
throw new Error(activation?.reason || 'Failed to activate Linux Electron audio routing.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!activation.monitorCaptureSupported) {
|
if (!activation.active) {
|
||||||
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
|
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
|
||||||
}
|
}
|
||||||
|
|
||||||
desktopStream = await this.startWithElectronDesktopCapturer({
|
desktopStream = await this.startWithElectronDesktopCapturer({
|
||||||
@@ -547,7 +656,7 @@ export class ScreenShareManager {
|
|||||||
|
|
||||||
this.linuxAudioRoutingResetPromise = this.resetLinuxElectronAudioRouting()
|
this.linuxAudioRoutingResetPromise = this.resetLinuxElectronAudioRouting()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.logger.warn('Failed to reset Linux Electron audio routing', error as any);
|
this.logger.warn('Failed to reset Linux Electron audio routing', error);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.linuxAudioRoutingResetPromise = null;
|
this.linuxAudioRoutingResetPromise = null;
|
||||||
@@ -563,7 +672,7 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resetLinuxElectronAudioRouting(): Promise<void> {
|
private async resetLinuxElectronAudioRouting(): Promise<void> {
|
||||||
const electronApi = typeof window !== 'undefined' ? (window as any).electronAPI : null;
|
const electronApi = this.getElectronApi();
|
||||||
const captureId = this.linuxMonitorAudioPipeline?.captureId;
|
const captureId = this.linuxMonitorAudioPipeline?.captureId;
|
||||||
|
|
||||||
this.linuxElectronAudioRoutingActive = false;
|
this.linuxElectronAudioRoutingActive = false;
|
||||||
@@ -575,7 +684,7 @@ export class ScreenShareManager {
|
|||||||
await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
|
await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('Failed to stop Linux screen-share monitor capture', error as any);
|
this.logger.warn('Failed to stop Linux screen-share monitor capture', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -583,7 +692,7 @@ export class ScreenShareManager {
|
|||||||
await electronApi.deactivateLinuxScreenShareAudioRouting();
|
await electronApi.deactivateLinuxScreenShareAudioRouting();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('Failed to deactivate Linux Electron audio routing', error as any);
|
this.logger.warn('Failed to deactivate Linux Electron audio routing', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +700,7 @@ export class ScreenShareManager {
|
|||||||
audioTrack: MediaStreamTrack;
|
audioTrack: MediaStreamTrack;
|
||||||
captureInfo: LinuxScreenShareMonitorCaptureInfo;
|
captureInfo: LinuxScreenShareMonitorCaptureInfo;
|
||||||
}> {
|
}> {
|
||||||
const electronApi = (window as any).electronAPI;
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
if (!electronApi?.startLinuxScreenShareMonitorCapture
|
if (!electronApi?.startLinuxScreenShareMonitorCapture
|
||||||
|| !electronApi?.stopLinuxScreenShareMonitorCapture
|
|| !electronApi?.stopLinuxScreenShareMonitorCapture
|
||||||
@@ -626,7 +735,7 @@ export class ScreenShareManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn('Linux screen-share monitor capture ended', payload as any);
|
this.logger.warn('Linux screen-share monitor capture ended', payload);
|
||||||
|
|
||||||
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === payload.captureId) {
|
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === payload.captureId) {
|
||||||
this.stopScreenShare();
|
this.stopScreenShare();
|
||||||
@@ -664,17 +773,19 @@ export class ScreenShareManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.linuxMonitorAudioPipeline = pipeline;
|
this.linuxMonitorAudioPipeline = pipeline;
|
||||||
|
const activeCaptureId = captureInfo.captureId;
|
||||||
|
|
||||||
audioTrack.addEventListener('ended', () => {
|
audioTrack.addEventListener('ended', () => {
|
||||||
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === captureInfo?.captureId) {
|
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === activeCaptureId) {
|
||||||
this.stopScreenShare();
|
this.stopScreenShare();
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
|
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
|
||||||
|
const activePipeline = pipeline;
|
||||||
|
|
||||||
queuedChunks.forEach((chunk) => {
|
queuedChunks.forEach((chunk) => {
|
||||||
this.handleLinuxScreenShareMonitorAudioChunk(pipeline!, chunk);
|
this.handleLinuxScreenShareMonitorAudioChunk(activePipeline, chunk);
|
||||||
});
|
});
|
||||||
|
|
||||||
queuedChunksByCaptureId.delete(captureInfo.captureId);
|
queuedChunksByCaptureId.delete(captureInfo.captureId);
|
||||||
@@ -699,7 +810,7 @@ export class ScreenShareManager {
|
|||||||
try {
|
try {
|
||||||
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
|
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
|
||||||
} catch (stopError) {
|
} catch (stopError) {
|
||||||
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError as any);
|
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
@@ -724,7 +835,7 @@ export class ScreenShareManager {
|
|||||||
pipeline.pendingBytes = new Uint8Array(0);
|
pipeline.pendingBytes = new Uint8Array(0);
|
||||||
|
|
||||||
void pipeline.audioContext.close().catch((error) => {
|
void pipeline.audioContext.close().catch((error) => {
|
||||||
this.logger.warn('Failed to close Linux screen-share monitor audio context', error as any);
|
this.logger.warn('Failed to close Linux screen-share monitor audio context', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +847,7 @@ export class ScreenShareManager {
|
|||||||
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
|
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
|
||||||
bitsPerSample: pipeline.bitsPerSample,
|
bitsPerSample: pipeline.bitsPerSample,
|
||||||
captureId: pipeline.captureId
|
captureId: pipeline.captureId
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -762,7 +873,7 @@ export class ScreenShareManager {
|
|||||||
|
|
||||||
if (pipeline.audioContext.state !== 'running') {
|
if (pipeline.audioContext.state !== 'running') {
|
||||||
void pipeline.audioContext.resume().catch((error) => {
|
void pipeline.audioContext.resume().catch((error) => {
|
||||||
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error as any);
|
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,8 +979,8 @@ export class ScreenShareManager {
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
options: ScreenShareStartOptions,
|
options: ScreenShareStartOptions,
|
||||||
preset: ScreenShareQualityPreset
|
preset: ScreenShareQualityPreset
|
||||||
): MediaStreamConstraints {
|
): ElectronDesktopMediaStreamConstraints {
|
||||||
const electronConstraints: any = {
|
const electronConstraints: ElectronDesktopMediaStreamConstraints = {
|
||||||
video: {
|
video: {
|
||||||
mandatory: {
|
mandatory: {
|
||||||
chromeMediaSource: 'desktop',
|
chromeMediaSource: 'desktop',
|
||||||
@@ -915,7 +1026,7 @@ export class ScreenShareManager {
|
|||||||
height: { ideal: preset.height, max: preset.height },
|
height: { ideal: preset.height, max: preset.height },
|
||||||
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
|
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
this.logger.warn('Failed to re-apply screen video constraints', error as any);
|
this.logger.warn('Failed to re-apply screen video constraints', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -947,12 +1058,7 @@ export class ScreenShareManager {
|
|||||||
maxFramerate: preset.frameRate
|
maxFramerate: preset.frameRate
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('Failed to apply screen-share sender parameters', error as any, { peerId });
|
this.logger.warn('Failed to apply screen-share sender parameters', error, { peerId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clean up all resources. */
|
|
||||||
destroy(): void {
|
|
||||||
this.stopScreenShare();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion,, max-statements-per-line */
|
||||||
/**
|
/**
|
||||||
* Manages the WebSocket connection to the signaling server,
|
* Manages the WebSocket connection to the signaling server,
|
||||||
* including automatic reconnection and heartbeats.
|
* including automatic reconnection and heartbeats.
|
||||||
@@ -18,6 +18,17 @@ import {
|
|||||||
SIGNALING_TYPE_VIEW_SERVER
|
SIGNALING_TYPE_VIEW_SERVER
|
||||||
} from './webrtc.constants';
|
} from './webrtc.constants';
|
||||||
|
|
||||||
|
interface ParsedSignalingPayload {
|
||||||
|
sdp?: RTCSessionDescriptionInit;
|
||||||
|
candidate?: RTCIceCandidateInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> &
|
||||||
|
Record<string, unknown> & {
|
||||||
|
type: string;
|
||||||
|
payload?: ParsedSignalingPayload;
|
||||||
|
};
|
||||||
|
|
||||||
export class SignalingManager {
|
export class SignalingManager {
|
||||||
private signalingWebSocket: WebSocket | null = null;
|
private signalingWebSocket: WebSocket | null = null;
|
||||||
private lastSignalingUrl: string | null = null;
|
private lastSignalingUrl: string | null = null;
|
||||||
@@ -29,7 +40,7 @@ export class SignalingManager {
|
|||||||
readonly heartbeatTick$ = new Subject<void>();
|
readonly heartbeatTick$ = new Subject<void>();
|
||||||
|
|
||||||
/** Fires whenever a raw signaling message arrives from the server. */
|
/** Fires whenever a raw signaling message arrives from the server. */
|
||||||
readonly messageReceived$ = new Subject<any>();
|
readonly messageReceived$ = new Subject<ParsedSignalingMessage>();
|
||||||
|
|
||||||
/** Fires when connection status changes (true = open, false = closed/error). */
|
/** Fires when connection status changes (true = open, false = closed/error). */
|
||||||
readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>();
|
readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>();
|
||||||
@@ -73,7 +84,7 @@ export class SignalingManager {
|
|||||||
const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null;
|
const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(rawPayload) as SignalingMessage & Record<string, unknown>;
|
const message = JSON.parse(rawPayload) as ParsedSignalingMessage;
|
||||||
const payloadPreview = this.buildPayloadPreview(message);
|
const payloadPreview = this.buildPayloadPreview(message);
|
||||||
|
|
||||||
recordDebugNetworkSignalingPayload(message, 'inbound');
|
recordDebugNetworkSignalingPayload(message, 'inbound');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* All log lines are prefixed with `[WebRTC]`.
|
* All log lines are prefixed with `[WebRTC]`.
|
||||||
*/
|
*/
|
||||||
export interface WebRTCTrafficDetails {
|
export interface WebRTCTrafficDetails {
|
||||||
|
[key: string]: unknown;
|
||||||
bytes?: number;
|
bytes?: number;
|
||||||
bufferedAmount?: number;
|
bufferedAmount?: number;
|
||||||
channelLabel?: string;
|
channelLabel?: string;
|
||||||
@@ -15,21 +16,14 @@ export interface WebRTCTrafficDetails {
|
|||||||
targetPeerId?: string;
|
targetPeerId?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebRTCLogger {
|
export class WebRTCLogger {
|
||||||
constructor(private readonly isEnabled: boolean | (() => boolean) = true) {}
|
constructor(private readonly isEnabled: boolean | (() => boolean) = true) {}
|
||||||
|
|
||||||
private get debugEnabled(): boolean {
|
|
||||||
return typeof this.isEnabled === 'function'
|
|
||||||
? this.isEnabled()
|
|
||||||
: this.isEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Informational log (only when debug is enabled). */
|
/** Informational log (only when debug is enabled). */
|
||||||
info(prefix: string, ...args: unknown[]): void {
|
info(prefix: string, ...args: unknown[]): void {
|
||||||
if (!this.debugEnabled)
|
if (!this.isDebugEnabled())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
||||||
@@ -37,7 +31,7 @@ export class WebRTCLogger {
|
|||||||
|
|
||||||
/** Warning log (only when debug is enabled). */
|
/** Warning log (only when debug is enabled). */
|
||||||
warn(prefix: string, ...args: unknown[]): void {
|
warn(prefix: string, ...args: unknown[]): void {
|
||||||
if (!this.debugEnabled)
|
if (!this.isDebugEnabled())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
||||||
@@ -50,10 +44,11 @@ export class WebRTCLogger {
|
|||||||
|
|
||||||
/** Error log (always emitted regardless of debug flag). */
|
/** Error log (always emitted regardless of debug flag). */
|
||||||
error(prefix: string, err: unknown, extra?: Record<string, unknown>): void {
|
error(prefix: string, err: unknown, extra?: Record<string, unknown>): void {
|
||||||
|
const errorDetails = this.extractErrorDetails(err);
|
||||||
const payload = {
|
const payload = {
|
||||||
name: (err as any)?.name,
|
name: errorDetails.name,
|
||||||
message: (err as any)?.message,
|
message: errorDetails.message,
|
||||||
stack: (err as any)?.stack,
|
stack: errorDetails.stack,
|
||||||
...extra
|
...extra
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +88,7 @@ export class WebRTCLogger {
|
|||||||
const videoTracks = stream.getVideoTracks();
|
const videoTracks = stream.getVideoTracks();
|
||||||
|
|
||||||
this.info(`Stream ready: ${label}`, {
|
this.info(`Stream ready: ${label}`, {
|
||||||
id: (stream as any).id,
|
id: stream.id,
|
||||||
audioTrackCount: audioTracks.length,
|
audioTrackCount: audioTracks.length,
|
||||||
videoTrackCount: videoTracks.length,
|
videoTrackCount: videoTracks.length,
|
||||||
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id,
|
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id,
|
||||||
@@ -103,4 +98,32 @@ export class WebRTCLogger {
|
|||||||
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
||||||
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isDebugEnabled(): boolean {
|
||||||
|
return typeof this.isEnabled === 'function'
|
||||||
|
? this.isEnabled()
|
||||||
|
: this.isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractErrorDetails(err: unknown): {
|
||||||
|
name?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
stack?: unknown;
|
||||||
|
} {
|
||||||
|
if (typeof err !== 'object' || err === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = err as {
|
||||||
|
name?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
stack?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: candidate.name,
|
||||||
|
message: candidate.message,
|
||||||
|
stack: candidate.stack
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
@@ -33,10 +33,7 @@ import {
|
|||||||
MAX_AUTO_SAVE_SIZE_BYTES
|
MAX_AUTO_SAVE_SIZE_BYTES
|
||||||
} from '../../../../../core/services/attachment.service';
|
} from '../../../../../core/services/attachment.service';
|
||||||
import { KlipyService } from '../../../../../core/services/klipy.service';
|
import { KlipyService } from '../../../../../core/services/klipy.service';
|
||||||
import {
|
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models';
|
||||||
DELETED_MESSAGE_CONTENT,
|
|
||||||
Message
|
|
||||||
} from '../../../../../core/models';
|
|
||||||
import {
|
import {
|
||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
ChatVideoPlayerComponent,
|
ChatVideoPlayerComponent,
|
||||||
@@ -81,7 +78,7 @@ const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
|||||||
const REMARK_PROCESSOR = unified()
|
const REMARK_PROCESSOR = unified()
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
.use(remarkBreaks) as any;
|
.use(remarkBreaks);
|
||||||
|
|
||||||
interface ChatMessageAttachmentViewModel extends Attachment {
|
interface ChatMessageAttachmentViewModel extends Attachment {
|
||||||
isAudio: boolean;
|
isAudio: boolean;
|
||||||
@@ -133,7 +130,7 @@ export class ChatMessageItemComponent {
|
|||||||
readonly repliedMessage = input<Message | undefined>();
|
readonly repliedMessage = input<Message | undefined>();
|
||||||
readonly currentUserId = input<string | null>(null);
|
readonly currentUserId = input<string | null>(null);
|
||||||
readonly isAdmin = input(false);
|
readonly isAdmin = input(false);
|
||||||
readonly remarkProcessor: any = REMARK_PROCESSOR;
|
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||||
|
|
||||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -19,6 +19,12 @@ const TYPING_TTL = 3_000;
|
|||||||
const PURGE_INTERVAL = 1_000;
|
const PURGE_INTERVAL = 1_000;
|
||||||
const MAX_SHOWN = 4;
|
const MAX_SHOWN = 4;
|
||||||
|
|
||||||
|
interface TypingSignalingMessage {
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
oderId: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-typing-indicator',
|
selector: 'app-typing-indicator',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -38,12 +44,16 @@ export class TypingIndicatorComponent {
|
|||||||
const webrtc = inject(WebRTCService);
|
const webrtc = inject(WebRTCService);
|
||||||
const destroyRef = inject(DestroyRef);
|
const destroyRef = inject(DestroyRef);
|
||||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||||
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
|
filter((msg): msg is TypingSignalingMessage =>
|
||||||
tap((msg: any) => {
|
msg?.type === 'user_typing' &&
|
||||||
|
typeof msg.displayName === 'string' &&
|
||||||
|
typeof msg.oderId === 'string'
|
||||||
|
),
|
||||||
|
tap((msg) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
this.typingMap.set(String(msg.oderId), {
|
this.typingMap.set(msg.oderId, {
|
||||||
name: String(msg.displayName),
|
name: msg.displayName,
|
||||||
expiresAt: now + TYPING_TTL
|
expiresAt: now + TYPING_TTL
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ import {
|
|||||||
selectVoiceChannels
|
selectVoiceChannels
|
||||||
} from '../../../store/rooms/rooms.selectors';
|
} from '../../../store/rooms/rooms.selectors';
|
||||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import {
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
selectCurrentUser,
|
|
||||||
selectIsCurrentUserAdmin
|
|
||||||
} from '../../../store/users/users.selectors';
|
|
||||||
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
|
import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -549,10 +549,13 @@ export class RoomsSidePanelComponent {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const peerKeys = [user?.oderId, user?.id, userId].filter(
|
const peerKeys = [
|
||||||
|
user?.oderId,
|
||||||
|
user?.id,
|
||||||
|
userId
|
||||||
|
].filter(
|
||||||
(candidate): candidate is string => !!candidate
|
(candidate): candidate is string => !!candidate
|
||||||
);
|
);
|
||||||
|
|
||||||
const stream = peerKeys
|
const stream = peerKeys
|
||||||
.map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey))
|
.map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey))
|
||||||
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
|
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ import { Router } from '@angular/router';
|
|||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucidePlus } from '@ng-icons/lucide';
|
import { lucidePlus } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import {
|
import { Room, User } from '../../core/models/index';
|
||||||
Room,
|
|
||||||
User
|
|
||||||
} from '../../core/models/index';
|
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import { Actions, ofType } from '@ngrx/effects';
|
||||||
Actions,
|
|
||||||
ofType
|
|
||||||
} from '@ngrx/effects';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { lucideX } from '@ng-icons/lucide';
|
import { lucideX } from '@ng-icons/lucide';
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { lucideUserX, lucideBan } from '@ng-icons/lucide';
|
|||||||
import {
|
import {
|
||||||
Room,
|
Room,
|
||||||
RoomMember,
|
RoomMember,
|
||||||
User,
|
|
||||||
UserRole
|
UserRole
|
||||||
} from '../../../../core/models/index';
|
} from '../../../../core/models/index';
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ import {
|
|||||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import {
|
import { Room, UserRole } from '../../../core/models/index';
|
||||||
Room,
|
|
||||||
UserRole
|
|
||||||
} from '../../../core/models/index';
|
|
||||||
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
|
|
||||||
@@ -191,7 +188,6 @@ export class SettingsModalComponent {
|
|||||||
const targetId = this.modal.targetServerId();
|
const targetId = this.modal.targetServerId();
|
||||||
const currentRoomId = this.currentRoom()?.id ?? null;
|
const currentRoomId = this.currentRoom()?.id ?? null;
|
||||||
const selectedId = this.selectedServerId();
|
const selectedId = this.selectedServerId();
|
||||||
|
|
||||||
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
|
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
|
||||||
|
|
||||||
if (!hasSelected) {
|
if (!hasSelected) {
|
||||||
|
|||||||
@@ -20,14 +20,8 @@ import { WebRTCService } from '../../../../core/services/webrtc.service';
|
|||||||
import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service';
|
import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service';
|
||||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||||
import { PlatformService } from '../../../../core/services/platform.service';
|
import { PlatformService } from '../../../../core/services/platform.service';
|
||||||
import {
|
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../core/services/voice-settings.storage';
|
||||||
loadVoiceSettingsFromStorage,
|
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../core/services/webrtc';
|
||||||
saveVoiceSettingsToStorage
|
|
||||||
} from '../../../../core/services/voice-settings.storage';
|
|
||||||
import {
|
|
||||||
SCREEN_SHARE_QUALITY_OPTIONS,
|
|
||||||
ScreenShareQuality
|
|
||||||
} from '../../../../core/services/webrtc';
|
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@@ -46,6 +40,10 @@ interface DesktopSettingsElectronApi {
|
|||||||
relaunchApp?: () => Promise<boolean>;
|
relaunchApp?: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DesktopSettingsWindow = Window & {
|
||||||
|
electronAPI?: DesktopSettingsElectronApi;
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-voice-settings',
|
selector: 'app-voice-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -87,7 +85,9 @@ export class VoiceSettingsComponent {
|
|||||||
askScreenShareQuality = signal(true);
|
askScreenShareQuality = signal(true);
|
||||||
hardwareAcceleration = signal(true);
|
hardwareAcceleration = signal(true);
|
||||||
hardwareAccelerationRestartRequired = signal(false);
|
hardwareAccelerationRestartRequired = signal(false);
|
||||||
readonly selectedScreenShareQualityDescription = computed(() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? '');
|
readonly selectedScreenShareQualityDescription = computed(
|
||||||
|
() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadVoiceSettings();
|
this.loadVoiceSettings();
|
||||||
@@ -294,7 +294,7 @@ export class VoiceSettingsComponent {
|
|||||||
|
|
||||||
private getElectronApi(): DesktopSettingsElectronApi | null {
|
private getElectronApi(): DesktopSettingsElectronApi | null {
|
||||||
return typeof window !== 'undefined'
|
return typeof window !== 'undefined'
|
||||||
? (window as any).electronAPI as DesktopSettingsElectronApi
|
? (window as DesktopSettingsWindow).electronAPI ?? null
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ import { PlatformService } from '../../core/services/platform.service';
|
|||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||||
import { LeaveServerDialogComponent } from '../../shared';
|
import { LeaveServerDialogComponent } from '../../shared';
|
||||||
|
|
||||||
|
interface WindowControlsAPI {
|
||||||
|
minimizeWindow?: () => void;
|
||||||
|
maximizeWindow?: () => void;
|
||||||
|
closeWindow?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElectronWindow = Window & {
|
||||||
|
electronAPI?: WindowControlsAPI;
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-title-bar',
|
selector: 'app-title-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -54,6 +64,10 @@ export class TitleBarComponent {
|
|||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
private platform = inject(PlatformService);
|
private platform = inject(PlatformService);
|
||||||
|
|
||||||
|
private getWindowControlsApi(): WindowControlsAPI | undefined {
|
||||||
|
return (window as ElectronWindow).electronAPI;
|
||||||
|
}
|
||||||
|
|
||||||
isElectron = computed(() => this.platform.isElectron);
|
isElectron = computed(() => this.platform.isElectron);
|
||||||
showMenuState = computed(() => false);
|
showMenuState = computed(() => false);
|
||||||
|
|
||||||
@@ -73,26 +87,23 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
/** Minimize the Electron window. */
|
/** Minimize the Electron window. */
|
||||||
minimize() {
|
minimize() {
|
||||||
const api = (window as any).electronAPI;
|
const api = this.getWindowControlsApi();
|
||||||
|
|
||||||
if (api?.minimizeWindow)
|
api?.minimizeWindow?.();
|
||||||
api.minimizeWindow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maximize or restore the Electron window. */
|
/** Maximize or restore the Electron window. */
|
||||||
maximize() {
|
maximize() {
|
||||||
const api = (window as any).electronAPI;
|
const api = this.getWindowControlsApi();
|
||||||
|
|
||||||
if (api?.maximizeWindow)
|
api?.maximizeWindow?.();
|
||||||
api.maximizeWindow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the Electron window. */
|
/** Close the Electron window. */
|
||||||
close() {
|
close() {
|
||||||
const api = (window as any).electronAPI;
|
const api = this.getWindowControlsApi();
|
||||||
|
|
||||||
if (api?.closeWindow)
|
api?.closeWindow?.();
|
||||||
api.closeWindow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate to the login page. */
|
/** Navigate to the login page. */
|
||||||
|
|||||||
@@ -23,22 +23,21 @@ import {
|
|||||||
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||||
import {
|
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage';
|
||||||
loadVoiceSettingsFromStorage,
|
|
||||||
saveVoiceSettingsToStorage
|
|
||||||
} from '../../../core/services/voice-settings.storage';
|
|
||||||
import { ScreenShareQuality } from '../../../core/services/webrtc';
|
import { ScreenShareQuality } from '../../../core/services/webrtc';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import {
|
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
|
||||||
DebugConsoleComponent,
|
|
||||||
ScreenShareQualityDialogComponent
|
|
||||||
} from '../../../shared';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-voice-controls',
|
selector: 'app-floating-voice-controls',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon, DebugConsoleComponent, ScreenShareQualityDialogComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NgIcon,
|
||||||
|
DebugConsoleComponent,
|
||||||
|
ScreenShareQualityDialogComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideMic,
|
lucideMic,
|
||||||
@@ -283,6 +282,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
includeSystemAudio: this.includeSystemAudio(),
|
includeSystemAudio: this.includeSystemAudio(),
|
||||||
quality
|
quality
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isScreenSharing.set(true);
|
this.isScreenSharing.set(true);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Screen share request was denied or failed
|
// Screen share request was denied or failed
|
||||||
|
|||||||
@@ -183,9 +183,9 @@ export class VoicePlaybackService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
const anyAudio = pipeline.audioElement as any;
|
const anyAudio = pipeline.audioElement as any;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line
|
||||||
const anyCtx = pipeline.context as any;
|
const anyCtx = pipeline.context as any;
|
||||||
const tasks: Promise<unknown>[] = [];
|
const tasks: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -17,17 +17,24 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { mergeMap } from 'rxjs/operators';
|
import { mergeMap } from 'rxjs/operators';
|
||||||
import { Action } from '@ngrx/store';
|
import { Action } from '@ngrx/store';
|
||||||
import { DELETED_MESSAGE_CONTENT, Message } from '../../core/models/index';
|
import {
|
||||||
|
DELETED_MESSAGE_CONTENT,
|
||||||
|
type ChatEvent,
|
||||||
|
type Message,
|
||||||
|
type Room,
|
||||||
|
type User
|
||||||
|
} from '../../core/models/index';
|
||||||
import type { DebuggingService } from '../../core/services';
|
import type { DebuggingService } from '../../core/services';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { AttachmentService } from '../../core/services/attachment.service';
|
import { AttachmentService, type AttachmentMeta } from '../../core/services/attachment.service';
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
import {
|
import {
|
||||||
INVENTORY_LIMIT,
|
INVENTORY_LIMIT,
|
||||||
CHUNK_SIZE,
|
CHUNK_SIZE,
|
||||||
FULL_SYNC_LIMIT,
|
FULL_SYNC_LIMIT,
|
||||||
|
type InventoryItem,
|
||||||
chunkArray,
|
chunkArray,
|
||||||
buildInventoryItem,
|
buildInventoryItem,
|
||||||
buildLocalInventoryMap,
|
buildLocalInventoryMap,
|
||||||
@@ -36,19 +43,64 @@ import {
|
|||||||
mergeIncomingMessage
|
mergeIncomingMessage
|
||||||
} from './messages.helpers';
|
} from './messages.helpers';
|
||||||
|
|
||||||
|
type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
|
||||||
|
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
|
||||||
|
type IncomingMessageType =
|
||||||
|
| ChatEvent['type']
|
||||||
|
| 'chat-inventory'
|
||||||
|
| 'chat-sync-request-ids'
|
||||||
|
| 'chat-sync-batch'
|
||||||
|
| 'chat-sync-summary'
|
||||||
|
| 'chat-sync-request'
|
||||||
|
| 'chat-sync-full'
|
||||||
|
| 'file-announce'
|
||||||
|
| 'file-chunk'
|
||||||
|
| 'file-request'
|
||||||
|
| 'file-cancel'
|
||||||
|
| 'file-not-found';
|
||||||
|
|
||||||
|
interface IncomingMessageEvent extends Omit<ChatEvent, 'type'> {
|
||||||
|
type: IncomingMessageType;
|
||||||
|
items?: InventoryItem[];
|
||||||
|
ids?: string[];
|
||||||
|
messages?: Message[];
|
||||||
|
attachments?: AttachmentMetaMap;
|
||||||
|
total?: number;
|
||||||
|
index?: number;
|
||||||
|
count?: number;
|
||||||
|
lastUpdated?: number;
|
||||||
|
file?: AnnouncedAttachment;
|
||||||
|
fileId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncBatchEvent = IncomingMessageEvent & {
|
||||||
|
messages: Message[];
|
||||||
|
attachments?: AttachmentMetaMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasMessageBatch(event: IncomingMessageEvent): event is SyncBatchEvent {
|
||||||
|
return Array.isArray(event.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAttachmentMetaMap(
|
||||||
|
attachmentMap: IncomingMessageEvent['attachments']
|
||||||
|
): attachmentMap is AttachmentMetaMap {
|
||||||
|
return typeof attachmentMap === 'object' && attachmentMap !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Shared context injected into each handler function. */
|
/** Shared context injected into each handler function. */
|
||||||
export interface IncomingMessageContext {
|
export interface IncomingMessageContext {
|
||||||
db: DatabaseService;
|
db: DatabaseService;
|
||||||
webrtc: WebRTCService;
|
webrtc: WebRTCService;
|
||||||
attachments: AttachmentService;
|
attachments: AttachmentService;
|
||||||
debugging: DebuggingService;
|
debugging: DebuggingService;
|
||||||
currentUser: any;
|
currentUser: User | null;
|
||||||
currentRoom: any;
|
currentRoom: Room | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signature for an incoming-message handler function. */
|
/** Signature for an incoming-message handler function. */
|
||||||
type MessageHandler = (
|
type MessageHandler = (
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
ctx: IncomingMessageContext,
|
ctx: IncomingMessageContext,
|
||||||
) => Observable<Action>;
|
) => Observable<Action>;
|
||||||
|
|
||||||
@@ -57,7 +109,7 @@ type MessageHandler = (
|
|||||||
* our local message inventory in chunks.
|
* our local message inventory in chunks.
|
||||||
*/
|
*/
|
||||||
function handleInventoryRequest(
|
function handleInventoryRequest(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, webrtc, attachments }: IncomingMessageContext
|
{ db, webrtc, attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const { roomId, fromPeerId } = event;
|
const { roomId, fromPeerId } = event;
|
||||||
@@ -83,13 +135,15 @@ function handleInventoryRequest(
|
|||||||
items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts);
|
items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts);
|
||||||
|
|
||||||
for (const chunk of chunkArray(items, CHUNK_SIZE)) {
|
for (const chunk of chunkArray(items, CHUNK_SIZE)) {
|
||||||
webrtc.sendToPeer(fromPeerId, {
|
const inventoryEvent: IncomingMessageEvent = {
|
||||||
type: 'chat-inventory',
|
type: 'chat-inventory',
|
||||||
roomId,
|
roomId,
|
||||||
items: chunk,
|
items: chunk,
|
||||||
total: items.length,
|
total: items.length,
|
||||||
index: 0
|
index: 0
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
webrtc.sendToPeer(fromPeerId, inventoryEvent);
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
).pipe(mergeMap(() => EMPTY));
|
).pipe(mergeMap(() => EMPTY));
|
||||||
@@ -100,7 +154,7 @@ function handleInventoryRequest(
|
|||||||
* and requests any missing or stale messages.
|
* and requests any missing or stale messages.
|
||||||
*/
|
*/
|
||||||
function handleInventory(
|
function handleInventory(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, webrtc, attachments }: IncomingMessageContext
|
{ db, webrtc, attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const { roomId, fromPeerId, items } = event;
|
const { roomId, fromPeerId, items } = event;
|
||||||
@@ -125,11 +179,13 @@ function handleInventory(
|
|||||||
const missing = findMissingIds(items, localMap);
|
const missing = findMissingIds(items, localMap);
|
||||||
|
|
||||||
for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
|
for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
|
||||||
webrtc.sendToPeer(fromPeerId, {
|
const syncRequestIdsEvent: IncomingMessageEvent = {
|
||||||
type: 'chat-sync-request-ids',
|
type: 'chat-sync-request-ids',
|
||||||
roomId,
|
roomId,
|
||||||
ids: chunk
|
ids: chunk
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
webrtc.sendToPeer(fromPeerId, syncRequestIdsEvent);
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
).pipe(mergeMap(() => EMPTY));
|
).pipe(mergeMap(() => EMPTY));
|
||||||
@@ -140,7 +196,7 @@ function handleInventory(
|
|||||||
* hydrated messages along with their attachment metadata.
|
* hydrated messages along with their attachment metadata.
|
||||||
*/
|
*/
|
||||||
function handleSyncRequestIds(
|
function handleSyncRequestIds(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, webrtc, attachments }: IncomingMessageContext
|
{ db, webrtc, attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const { roomId, ids, fromPeerId } = event;
|
const { roomId, ids, fromPeerId } = event;
|
||||||
@@ -164,14 +220,14 @@ function handleSyncRequestIds(
|
|||||||
attachments.getAttachmentMetasForMessages(msgIds);
|
attachments.getAttachmentMetasForMessages(msgIds);
|
||||||
|
|
||||||
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
|
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
|
||||||
const chunkAttachments: Record<string, any> = {};
|
const chunkAttachments: AttachmentMetaMap = {};
|
||||||
|
|
||||||
for (const hydratedMessage of chunk) {
|
for (const hydratedMessage of chunk) {
|
||||||
if (attachmentMetas[hydratedMessage.id])
|
if (attachmentMetas[hydratedMessage.id])
|
||||||
chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id];
|
chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
webrtc.sendToPeer(fromPeerId, {
|
const syncBatchEvent: IncomingMessageEvent = {
|
||||||
type: 'chat-sync-batch',
|
type: 'chat-sync-batch',
|
||||||
roomId: roomId || '',
|
roomId: roomId || '',
|
||||||
messages: chunk,
|
messages: chunk,
|
||||||
@@ -179,7 +235,9 @@ function handleSyncRequestIds(
|
|||||||
Object.keys(chunkAttachments).length > 0
|
Object.keys(chunkAttachments).length > 0
|
||||||
? chunkAttachments
|
? chunkAttachments
|
||||||
: undefined
|
: undefined
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
webrtc.sendToPeer(fromPeerId, syncBatchEvent);
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
).pipe(mergeMap(() => EMPTY));
|
).pipe(mergeMap(() => EMPTY));
|
||||||
@@ -191,13 +249,13 @@ function handleSyncRequestIds(
|
|||||||
* missing image attachments.
|
* missing image attachments.
|
||||||
*/
|
*/
|
||||||
function handleSyncBatch(
|
function handleSyncBatch(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, attachments }: IncomingMessageContext
|
{ db, attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!Array.isArray(event.messages))
|
if (!hasMessageBatch(event))
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
if (event.attachments && typeof event.attachments === 'object') {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
attachments.registerSyncedAttachments(event.attachments);
|
attachments.registerSyncedAttachments(event.attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,13 +270,13 @@ function handleSyncBatch(
|
|||||||
|
|
||||||
/** Merges each incoming message and collects those that changed. */
|
/** Merges each incoming message and collects those that changed. */
|
||||||
async function processSyncBatch(
|
async function processSyncBatch(
|
||||||
event: any,
|
event: SyncBatchEvent,
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
attachments: AttachmentService
|
attachments: AttachmentService
|
||||||
): Promise<Message[]> {
|
): Promise<Message[]> {
|
||||||
const toUpsert: Message[] = [];
|
const toUpsert: Message[] = [];
|
||||||
|
|
||||||
for (const incoming of event.messages as Message[]) {
|
for (const incoming of event.messages) {
|
||||||
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
||||||
|
|
||||||
if (incoming.isDeleted) {
|
if (incoming.isDeleted) {
|
||||||
@@ -233,7 +291,7 @@ async function processSyncBatch(
|
|||||||
toUpsert.push(message);
|
toUpsert.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.attachments && typeof event.attachments === 'object') {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
requestMissingImages(event.attachments, attachments);
|
requestMissingImages(event.attachments, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +300,7 @@ async function processSyncBatch(
|
|||||||
|
|
||||||
/** Auto-requests any unavailable image attachments from any connected peer. */
|
/** Auto-requests any unavailable image attachments from any connected peer. */
|
||||||
function requestMissingImages(
|
function requestMissingImages(
|
||||||
attachmentMap: Record<string, any[]>,
|
attachmentMap: AttachmentMetaMap,
|
||||||
attachments: AttachmentService
|
attachments: AttachmentService
|
||||||
): void {
|
): void {
|
||||||
for (const [msgId, metas] of Object.entries(attachmentMap)) {
|
for (const [msgId, metas] of Object.entries(attachmentMap)) {
|
||||||
@@ -251,7 +309,7 @@ function requestMissingImages(
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
const atts = attachments.getForMessage(msgId);
|
const atts = attachments.getForMessage(msgId);
|
||||||
const matchingAttachment = atts.find((attachment: any) => attachment.id === meta.id);
|
const matchingAttachment = atts.find((attachment) => attachment.id === meta.id);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
matchingAttachment &&
|
matchingAttachment &&
|
||||||
@@ -266,7 +324,7 @@ function requestMissingImages(
|
|||||||
|
|
||||||
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
|
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
|
||||||
function handleChatMessage(
|
function handleChatMessage(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, debugging, currentUser }: IncomingMessageContext
|
{ db, debugging, currentUser }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const msg = event.message;
|
const msg = event.message;
|
||||||
@@ -300,21 +358,25 @@ function handleChatMessage(
|
|||||||
|
|
||||||
/** Applies a remote message edit to the local DB and store. */
|
/** Applies a remote message edit to the local DB and store. */
|
||||||
function handleMessageEdited(
|
function handleMessageEdited(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, debugging }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId || !event.content)
|
if (!event.messageId || !event.content)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
|
const editedAt = typeof event.editedAt === 'number'
|
||||||
|
? event.editedAt
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
trackBackgroundOperation(
|
trackBackgroundOperation(
|
||||||
db.updateMessage(event.messageId, {
|
db.updateMessage(event.messageId, {
|
||||||
content: event.content,
|
content: event.content,
|
||||||
editedAt: event.editedAt
|
editedAt
|
||||||
}),
|
}),
|
||||||
debugging,
|
debugging,
|
||||||
'Failed to persist incoming message edit',
|
'Failed to persist incoming message edit',
|
||||||
{
|
{
|
||||||
editedAt: event.editedAt ?? null,
|
editedAt,
|
||||||
fromPeerId: event.fromPeerId ?? null,
|
fromPeerId: event.fromPeerId ?? null,
|
||||||
messageId: event.messageId
|
messageId: event.messageId
|
||||||
}
|
}
|
||||||
@@ -324,14 +386,14 @@ function handleMessageEdited(
|
|||||||
MessagesActions.editMessageSuccess({
|
MessagesActions.editMessageSuccess({
|
||||||
messageId: event.messageId,
|
messageId: event.messageId,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
editedAt: event.editedAt
|
editedAt
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies a remote message deletion to the local DB and store. */
|
/** Applies a remote message deletion to the local DB and store. */
|
||||||
function handleMessageDeleted(
|
function handleMessageDeleted(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, debugging, attachments }: IncomingMessageContext
|
{ db, debugging, attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId)
|
if (!event.messageId)
|
||||||
@@ -375,7 +437,7 @@ function handleMessageDeleted(
|
|||||||
|
|
||||||
/** Saves an incoming reaction to DB and updates the store. */
|
/** Saves an incoming reaction to DB and updates the store. */
|
||||||
function handleReactionAdded(
|
function handleReactionAdded(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, debugging }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId || !event.reaction)
|
if (!event.messageId || !event.reaction)
|
||||||
@@ -398,7 +460,7 @@ function handleReactionAdded(
|
|||||||
|
|
||||||
/** Removes a reaction from DB and updates the store. */
|
/** Removes a reaction from DB and updates the store. */
|
||||||
function handleReactionRemoved(
|
function handleReactionRemoved(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, debugging }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId || !event.oderId || !event.emoji)
|
if (!event.messageId || !event.oderId || !event.emoji)
|
||||||
@@ -426,7 +488,7 @@ function handleReactionRemoved(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFileAnnounce(
|
function handleFileAnnounce(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ attachments }: IncomingMessageContext
|
{ attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
attachments.handleFileAnnounce(event);
|
attachments.handleFileAnnounce(event);
|
||||||
@@ -434,7 +496,7 @@ function handleFileAnnounce(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFileChunk(
|
function handleFileChunk(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ attachments }: IncomingMessageContext
|
{ attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
attachments.handleFileChunk(event);
|
attachments.handleFileChunk(event);
|
||||||
@@ -442,7 +504,7 @@ function handleFileChunk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFileRequest(
|
function handleFileRequest(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ attachments }: IncomingMessageContext
|
{ attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
attachments.handleFileRequest(event);
|
attachments.handleFileRequest(event);
|
||||||
@@ -450,7 +512,7 @@ function handleFileRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFileCancel(
|
function handleFileCancel(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ attachments }: IncomingMessageContext
|
{ attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
attachments.handleFileCancel(event);
|
attachments.handleFileCancel(event);
|
||||||
@@ -458,7 +520,7 @@ function handleFileCancel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFileNotFound(
|
function handleFileNotFound(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ attachments }: IncomingMessageContext
|
{ attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
attachments.handleFileNotFound(event);
|
attachments.handleFileNotFound(event);
|
||||||
@@ -470,7 +532,7 @@ function handleFileNotFound(
|
|||||||
* if the peer has newer or more data.
|
* if the peer has newer or more data.
|
||||||
*/
|
*/
|
||||||
function handleSyncSummary(
|
function handleSyncSummary(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, webrtc, currentRoom }: IncomingMessageContext
|
{ db, webrtc, currentRoom }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!currentRoom)
|
if (!currentRoom)
|
||||||
@@ -491,12 +553,15 @@ function handleSyncSummary(
|
|||||||
const needsSync =
|
const needsSync =
|
||||||
remoteLastUpdated > localLastUpdated ||
|
remoteLastUpdated > localLastUpdated ||
|
||||||
(remoteLastUpdated === localLastUpdated && remoteCount > localCount);
|
(remoteLastUpdated === localLastUpdated && remoteCount > localCount);
|
||||||
|
const fromPeerId = event.fromPeerId;
|
||||||
|
|
||||||
if (!identical && needsSync && event.fromPeerId) {
|
if (!identical && needsSync && fromPeerId) {
|
||||||
webrtc.sendToPeer(event.fromPeerId, {
|
const syncRequestEvent: IncomingMessageEvent = {
|
||||||
type: 'chat-sync-request',
|
type: 'chat-sync-request',
|
||||||
roomId: currentRoom.id
|
roomId: currentRoom.id
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
webrtc.sendToPeer(fromPeerId, syncRequestEvent);
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
).pipe(mergeMap(() => EMPTY));
|
).pipe(mergeMap(() => EMPTY));
|
||||||
@@ -504,31 +569,34 @@ function handleSyncSummary(
|
|||||||
|
|
||||||
/** Responds to a peer's full sync request by sending all local messages. */
|
/** Responds to a peer's full sync request by sending all local messages. */
|
||||||
function handleSyncRequest(
|
function handleSyncRequest(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, webrtc, currentRoom }: IncomingMessageContext
|
{ db, webrtc, currentRoom }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!currentRoom || !event.fromPeerId)
|
const fromPeerId = event.fromPeerId;
|
||||||
|
|
||||||
|
if (!currentRoom || !fromPeerId)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
return from(
|
return from(
|
||||||
(async () => {
|
(async () => {
|
||||||
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
|
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
|
||||||
|
const syncFullEvent: IncomingMessageEvent = {
|
||||||
webrtc.sendToPeer(event.fromPeerId, {
|
|
||||||
type: 'chat-sync-full',
|
type: 'chat-sync-full',
|
||||||
roomId: currentRoom.id,
|
roomId: currentRoom.id,
|
||||||
messages: all
|
messages: all
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
webrtc.sendToPeer(fromPeerId, syncFullEvent);
|
||||||
})()
|
})()
|
||||||
).pipe(mergeMap(() => EMPTY));
|
).pipe(mergeMap(() => EMPTY));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merges a full message dump from a peer into the local DB and store. */
|
/** Merges a full message dump from a peer into the local DB and store. */
|
||||||
function handleSyncFull(
|
function handleSyncFull(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
{ db, attachments }: IncomingMessageContext
|
{ db, attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messages || !Array.isArray(event.messages))
|
if (!hasMessageBatch(event))
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
return from(processSyncBatch(event, db, attachments)).pipe(
|
return from(processSyncBatch(event, db, attachments)).pipe(
|
||||||
@@ -575,7 +643,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
|
|||||||
* Returns `EMPTY` if the event type is unknown or has no relevant handler.
|
* Returns `EMPTY` if the event type is unknown or has no relevant handler.
|
||||||
*/
|
*/
|
||||||
export function dispatchIncomingMessage(
|
export function dispatchIncomingMessage(
|
||||||
event: any,
|
event: IncomingMessageEvent,
|
||||||
ctx: IncomingMessageContext
|
ctx: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const handler = HANDLER_MAP[event.type];
|
const handler = HANDLER_MAP[event.type];
|
||||||
|
|||||||
@@ -89,12 +89,12 @@ export class MessagesSyncEffects {
|
|||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
count,
|
count,
|
||||||
lastUpdated
|
lastUpdated
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
this.webrtc.sendToPeer(peerId, {
|
this.webrtc.sendToPeer(peerId, {
|
||||||
type: 'chat-inventory-request',
|
type: 'chat-inventory-request',
|
||||||
roomId: room.id
|
roomId: room.id
|
||||||
} as any);
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -131,12 +131,12 @@ export class MessagesSyncEffects {
|
|||||||
roomId: activeRoom.id,
|
roomId: activeRoom.id,
|
||||||
count,
|
count,
|
||||||
lastUpdated
|
lastUpdated
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
this.webrtc.sendToPeer(pid, {
|
this.webrtc.sendToPeer(pid, {
|
||||||
type: 'chat-inventory-request',
|
type: 'chat-inventory-request',
|
||||||
roomId: activeRoom.id
|
roomId: activeRoom.id
|
||||||
} as any);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
|
this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
|
||||||
error,
|
error,
|
||||||
@@ -186,7 +186,7 @@ export class MessagesSyncEffects {
|
|||||||
this.webrtc.sendToPeer(pid, {
|
this.webrtc.sendToPeer(pid, {
|
||||||
type: 'chat-inventory-request',
|
type: 'chat-inventory-request',
|
||||||
roomId: room.id
|
roomId: room.id
|
||||||
} as any);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
|
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ export class MessagesEffects {
|
|||||||
webrtc: this.webrtc,
|
webrtc: this.webrtc,
|
||||||
attachments: this.attachments,
|
attachments: this.attachments,
|
||||||
debugging: this.debugging,
|
debugging: this.debugging,
|
||||||
currentUser,
|
currentUser: currentUser ?? null,
|
||||||
currentRoom
|
currentRoom
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export async function buildLocalInventoryMap(
|
|||||||
rc: 0,
|
rc: 0,
|
||||||
ac: 0
|
ac: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import {
|
|||||||
EntityAdapter,
|
EntityAdapter,
|
||||||
createEntityAdapter
|
createEntityAdapter
|
||||||
} from '@ngrx/entity';
|
} from '@ngrx/entity';
|
||||||
import {
|
import { DELETED_MESSAGE_CONTENT, Message } from '../../core/models/index';
|
||||||
DELETED_MESSAGE_CONTENT,
|
|
||||||
Message
|
|
||||||
} from '../../core/models/index';
|
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
|
|
||||||
/** State shape for the messages feature slice, extending NgRx EntityState. */
|
/** State shape for the messages feature slice, extending NgRx EntityState. */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
withLatestFrom
|
withLatestFrom
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
RoomMember,
|
RoomMember,
|
||||||
User
|
User
|
||||||
@@ -127,7 +128,13 @@ export class RoomMembersSyncEffects {
|
|||||||
savedRooms,
|
savedRooms,
|
||||||
currentUser
|
currentUser
|
||||||
]) => {
|
]) => {
|
||||||
const signalingMessage = message as any;
|
const signalingMessage: {
|
||||||
|
type: string;
|
||||||
|
serverId?: string;
|
||||||
|
users?: { oderId: string; displayName: string }[];
|
||||||
|
oderId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
} = message;
|
||||||
const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined;
|
const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
@@ -159,9 +166,14 @@ export class RoomMembersSyncEffects {
|
|||||||
if (!signalingMessage.oderId || signalingMessage.oderId === myId)
|
if (!signalingMessage.oderId || signalingMessage.oderId === myId)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
|
const joinedUser = {
|
||||||
|
oderId: signalingMessage.oderId,
|
||||||
|
displayName: signalingMessage.displayName
|
||||||
|
};
|
||||||
|
|
||||||
const members = upsertRoomMember(
|
const members = upsertRoomMember(
|
||||||
room.members ?? [],
|
room.members ?? [],
|
||||||
this.buildPresenceMember(room, signalingMessage)
|
this.buildPresenceMember(room, joinedUser)
|
||||||
);
|
);
|
||||||
const actions = this.createRoomMemberUpdateActions(room, members);
|
const actions = this.createRoomMemberUpdateActions(room, members);
|
||||||
|
|
||||||
@@ -197,7 +209,7 @@ export class RoomMembersSyncEffects {
|
|||||||
this.webrtc.sendToPeer(peerId, {
|
this.webrtc.sendToPeer(peerId, {
|
||||||
type: 'member-roster-request',
|
type: 'member-roster-request',
|
||||||
roomId: currentRoom.id
|
roomId: currentRoom.id
|
||||||
} as any);
|
});
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
@@ -218,7 +230,7 @@ export class RoomMembersSyncEffects {
|
|||||||
this.webrtc.sendToPeer(peerId, {
|
this.webrtc.sendToPeer(peerId, {
|
||||||
type: 'member-roster-request',
|
type: 'member-roster-request',
|
||||||
roomId: room.id
|
roomId: room.id
|
||||||
} as any);
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* peer may have disconnected */
|
/* peer may have disconnected */
|
||||||
}
|
}
|
||||||
@@ -333,7 +345,7 @@ export class RoomMembersSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMemberRosterRequest(
|
private handleMemberRosterRequest(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[],
|
savedRooms: Room[],
|
||||||
currentUser: User | null
|
currentUser: User | null
|
||||||
@@ -358,13 +370,13 @@ export class RoomMembersSyncEffects {
|
|||||||
type: 'member-roster',
|
type: 'member-roster',
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
members
|
members
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
return this.createRoomMemberUpdateActions(room, members);
|
return this.createRoomMemberUpdateActions(room, members);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMemberRoster(
|
private handleMemberRoster(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[],
|
savedRooms: Room[],
|
||||||
currentUser: User | null
|
currentUser: User | null
|
||||||
@@ -387,7 +399,7 @@ export class RoomMembersSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMemberLeave(
|
private handleMemberLeave(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[]
|
savedRooms: Room[]
|
||||||
): Action[] {
|
): Action[] {
|
||||||
@@ -401,10 +413,11 @@ export class RoomMembersSyncEffects {
|
|||||||
room,
|
room,
|
||||||
removeRoomMember(room.members ?? [], event.targetUserId, event.oderId)
|
removeRoomMember(room.members ?? [], event.targetUserId, event.oderId)
|
||||||
);
|
);
|
||||||
|
const departedUserId = event.oderId ?? event.targetUserId;
|
||||||
|
|
||||||
if (currentRoom?.id === room.id && (event.oderId || event.targetUserId)) {
|
if (currentRoom?.id === room.id && departedUserId) {
|
||||||
actions.push(
|
actions.push(
|
||||||
UsersActions.userLeft({ userId: event.oderId || event.targetUserId })
|
UsersActions.userLeft({ userId: departedUserId })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +425,7 @@ export class RoomMembersSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleIncomingHostChange(
|
private handleIncomingHostChange(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[],
|
savedRooms: Room[],
|
||||||
currentUser: User | null
|
currentUser: User | null
|
||||||
@@ -434,7 +447,7 @@ export class RoomMembersSyncEffects {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
{
|
{
|
||||||
id: event.previousHostId,
|
id: event.previousHostId ?? event.previousHostOderId ?? '',
|
||||||
oderId: event.previousHostOderId
|
oderId: event.previousHostOderId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -484,7 +497,7 @@ export class RoomMembersSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleIncomingRoleChange(
|
private handleIncomingRoleChange(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[]
|
savedRooms: Room[]
|
||||||
): Action[] {
|
): Action[] {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
/* eslint-disable id-length */
|
/* eslint-disable id-length */
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, complexity */
|
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
@@ -34,10 +34,12 @@ import { DatabaseService } from '../../core/services/database.service';
|
|||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
import {
|
import {
|
||||||
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
RoomSettings,
|
RoomSettings,
|
||||||
RoomPermissions,
|
RoomPermissions,
|
||||||
BanEntry,
|
BanEntry,
|
||||||
|
User,
|
||||||
VoiceState
|
VoiceState
|
||||||
} from '../../core/models/index';
|
} from '../../core/models/index';
|
||||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||||
@@ -50,14 +52,16 @@ import {
|
|||||||
|
|
||||||
/** Build a minimal User object from signaling payload. */
|
/** Build a minimal User object from signaling payload. */
|
||||||
function buildSignalingUser(
|
function buildSignalingUser(
|
||||||
data: { oderId: string; displayName: string },
|
data: { oderId: string; displayName?: string },
|
||||||
extras: Record<string, unknown> = {}
|
extras: Record<string, unknown> = {}
|
||||||
) {
|
) {
|
||||||
|
const displayName = data.displayName || 'User';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oderId: data.oderId,
|
oderId: data.oderId,
|
||||||
id: data.oderId,
|
id: data.oderId,
|
||||||
username: data.displayName.toLowerCase().replace(/\s+/g, '_'),
|
username: displayName.toLowerCase().replace(/\s+/g, '_'),
|
||||||
displayName: data.displayName,
|
displayName,
|
||||||
status: 'online' as const,
|
status: 'online' as const,
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
role: 'member' as const,
|
role: 'member' as const,
|
||||||
@@ -89,6 +93,18 @@ function isWrongServer(
|
|||||||
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RoomPresenceSignalingMessage {
|
||||||
|
type: string;
|
||||||
|
serverId?: string;
|
||||||
|
users?: { oderId: string; displayName: string }[];
|
||||||
|
oderId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockedRoomAccessAction =
|
||||||
|
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||||
|
| ReturnType<typeof RoomsActions.joinRoomFailure>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoomsEffects {
|
export class RoomsEffects {
|
||||||
private actions$ = inject(Actions);
|
private actions$ = inject(Actions);
|
||||||
@@ -612,7 +628,7 @@ export class RoomsEffects {
|
|||||||
roomId,
|
roomId,
|
||||||
icon,
|
icon,
|
||||||
iconUpdatedAt
|
iconUpdatedAt
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
return of(RoomsActions.updateServerIconSuccess({ roomId,
|
return of(RoomsActions.updateServerIconSuccess({ roomId,
|
||||||
icon,
|
icon,
|
||||||
@@ -678,17 +694,18 @@ export class RoomsEffects {
|
|||||||
mergeMap(([
|
mergeMap(([
|
||||||
message,
|
message,
|
||||||
currentUser,
|
currentUser,
|
||||||
currentRoom]: [any, any, any
|
currentRoom
|
||||||
]) => {
|
]) => {
|
||||||
|
const signalingMessage: RoomPresenceSignalingMessage = message;
|
||||||
const myId = currentUser?.oderId || currentUser?.id;
|
const myId = currentUser?.oderId || currentUser?.id;
|
||||||
const viewedServerId = currentRoom?.id;
|
const viewedServerId = currentRoom?.id;
|
||||||
|
|
||||||
switch (message.type) {
|
switch (signalingMessage.type) {
|
||||||
case 'server_users': {
|
case 'server_users': {
|
||||||
if (!message.users || isWrongServer(message.serverId, viewedServerId))
|
if (!signalingMessage.users || isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
const joinActions = (message.users as { oderId: string; displayName: string }[])
|
const joinActions = signalingMessage.users
|
||||||
.filter((u) => u.oderId !== myId)
|
.filter((u) => u.oderId !== myId)
|
||||||
.map((u) =>
|
.map((u) =>
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
@@ -700,22 +717,33 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'user_joined': {
|
case 'user_joined': {
|
||||||
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId)
|
if (isWrongServer(signalingMessage.serverId, viewedServerId) || signalingMessage.oderId === myId)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
|
if (!signalingMessage.oderId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const joinedUser = {
|
||||||
|
oderId: signalingMessage.oderId,
|
||||||
|
displayName: signalingMessage.displayName
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
user: buildSignalingUser(message, buildKnownUserExtras(currentRoom, message.oderId))
|
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'user_left': {
|
case 'user_left': {
|
||||||
if (isWrongServer(message.serverId, viewedServerId))
|
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
this.knownVoiceUsers.delete(message.oderId);
|
if (!signalingMessage.oderId)
|
||||||
return [UsersActions.userLeft({ userId: message.oderId })];
|
return EMPTY;
|
||||||
|
|
||||||
|
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
||||||
|
return [UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -811,7 +839,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
private handleVoiceOrScreenState(event: any, allUsers: any[], kind: 'voice' | 'screen') {
|
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], kind: 'voice' | 'screen') {
|
||||||
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
||||||
|
|
||||||
if (!userId)
|
if (!userId)
|
||||||
@@ -964,16 +992,17 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleServerStateRequest(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
private handleServerStateRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const fromPeerId = event.fromPeerId;
|
||||||
|
|
||||||
if (!room || !event.fromPeerId)
|
if (!room || !fromPeerId)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
return from(this.db.getBansForRoom(room.id)).pipe(
|
return from(this.db.getBansForRoom(room.id)).pipe(
|
||||||
tap((bans) => {
|
tap((bans) => {
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
this.webrtc.sendToPeer(fromPeerId, {
|
||||||
type: 'server-state-full',
|
type: 'server-state-full',
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
room,
|
room,
|
||||||
@@ -985,7 +1014,7 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleServerStateFull(
|
private handleServerStateFull(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[],
|
savedRooms: Room[],
|
||||||
currentUser: { id: string; oderId: string } | null
|
currentUser: { id: string; oderId: string } | null
|
||||||
@@ -1002,17 +1031,14 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
return this.syncBansToLocalRoom(room.id, bans).pipe(
|
return this.syncBansToLocalRoom(room.id, bans).pipe(
|
||||||
mergeMap(() => {
|
mergeMap(() => {
|
||||||
const actions: Array<
|
const actions: (ReturnType<typeof RoomsActions.updateRoom>
|
||||||
ReturnType<typeof RoomsActions.updateRoom>
|
|
||||||
| ReturnType<typeof UsersActions.loadBansSuccess>
|
| ReturnType<typeof UsersActions.loadBansSuccess>
|
||||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
| ReturnType<typeof RoomsActions.forgetRoom>)[] = [
|
||||||
> = [
|
|
||||||
RoomsActions.updateRoom({
|
RoomsActions.updateRoom({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
changes: roomChanges
|
changes: roomChanges
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
const isCurrentUserBanned = hasRoomBanForUser(
|
const isCurrentUserBanned = hasRoomBanForUser(
|
||||||
bans,
|
bans,
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -1033,7 +1059,7 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRoomSettingsUpdate(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
private handleRoomSettingsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const settings = event.settings as Partial<RoomSettings> | undefined;
|
const settings = event.settings as Partial<RoomSettings> | undefined;
|
||||||
@@ -1056,7 +1082,7 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRoomPermissionsUpdate(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
private handleRoomPermissionsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
||||||
@@ -1075,7 +1101,7 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconSummary(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
@@ -1089,13 +1115,13 @@ export class RoomsEffects {
|
|||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
type: 'server-icon-request',
|
type: 'server-icon-request',
|
||||||
roomId: room.id
|
roomId: room.id
|
||||||
} as any);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconRequest(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
private handleIconRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
@@ -1108,16 +1134,16 @@ export class RoomsEffects {
|
|||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
icon: room.icon,
|
icon: room.icon,
|
||||||
iconUpdatedAt: room.iconUpdatedAt || 0
|
iconUpdatedAt: room.iconUpdatedAt || 0
|
||||||
} as any);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconData(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
private handleIconData(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const senderId = event.fromPeerId as string | undefined;
|
const senderId = event.fromPeerId;
|
||||||
|
|
||||||
if (!room || typeof event.icon !== 'string' || !senderId)
|
if (!room || typeof event.icon !== 'string' || !senderId)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
@@ -1164,7 +1190,7 @@ export class RoomsEffects {
|
|||||||
type: 'server-icon-summary',
|
type: 'server-icon-summary',
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
iconUpdatedAt
|
iconUpdatedAt
|
||||||
} as any);
|
});
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
@@ -1177,16 +1203,14 @@ export class RoomsEffects {
|
|||||||
private async getBlockedRoomAccessActions(
|
private async getBlockedRoomAccessActions(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
currentUser: { id: string; oderId: string } | null
|
currentUser: { id: string; oderId: string } | null
|
||||||
): Promise<Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>>> {
|
): Promise<BlockedRoomAccessAction[]> {
|
||||||
const bans = await this.db.getBansForRoom(roomId);
|
const bans = await this.db.getBansForRoom(roomId);
|
||||||
|
|
||||||
if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) {
|
if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockedActions: Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>> = [
|
const blockedActions: BlockedRoomAccessAction[] = [RoomsActions.joinRoomFailure({ error: 'You are banned from this server' })];
|
||||||
RoomsActions.joinRoomFailure({ error: 'You are banned from this server' })
|
|
||||||
];
|
|
||||||
const storedRoom = await this.db.getRoom(roomId);
|
const storedRoom = await this.db.getRoom(roomId);
|
||||||
|
|
||||||
if (storedRoom) {
|
if (storedRoom) {
|
||||||
|
|||||||
@@ -31,21 +31,25 @@ import {
|
|||||||
selectCurrentUserId,
|
selectCurrentUserId,
|
||||||
selectHostId
|
selectHostId
|
||||||
} from './users.selectors';
|
} from './users.selectors';
|
||||||
import {
|
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
||||||
selectCurrentRoom,
|
|
||||||
selectSavedRooms
|
|
||||||
} from '../rooms/rooms.selectors';
|
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import {
|
import {
|
||||||
BanEntry,
|
BanEntry,
|
||||||
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
User
|
User
|
||||||
} from '../../core/models/index';
|
} from '../../core/models/index';
|
||||||
import {
|
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
||||||
findRoomMember,
|
|
||||||
removeRoomMember
|
type IncomingModerationExtraAction =
|
||||||
} from '../rooms/room-members.helpers';
|
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||||
|
| ReturnType<typeof UsersActions.kickUserSuccess>
|
||||||
|
| ReturnType<typeof UsersActions.banUserSuccess>;
|
||||||
|
|
||||||
|
type IncomingModerationAction =
|
||||||
|
| ReturnType<typeof RoomsActions.updateRoom>
|
||||||
|
| IncomingModerationExtraAction;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersEffects {
|
export class UsersEffects {
|
||||||
@@ -213,7 +217,6 @@ export class UsersEffects {
|
|||||||
const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
||||||
const targetMember = findRoomMember(room.members ?? [], userId);
|
const targetMember = findRoomMember(room.members ?? [], userId);
|
||||||
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
||||||
|
|
||||||
const ban: BanEntry = {
|
const ban: BanEntry = {
|
||||||
oderId: uuidv4(),
|
oderId: uuidv4(),
|
||||||
userId,
|
userId,
|
||||||
@@ -236,10 +239,8 @@ export class UsersEffects {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
mergeMap(() => {
|
mergeMap(() => {
|
||||||
const actions: Array<
|
const actions: (ReturnType<typeof RoomsActions.updateRoom>
|
||||||
ReturnType<typeof RoomsActions.updateRoom>
|
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
|
||||||
| ReturnType<typeof UsersActions.banUserSuccess>
|
|
||||||
> = [
|
|
||||||
RoomsActions.updateRoom({ roomId: room.id,
|
RoomsActions.updateRoom({ roomId: room.id,
|
||||||
changes: { members: nextMembers } })
|
changes: { members: nextMembers } })
|
||||||
];
|
];
|
||||||
@@ -433,14 +434,9 @@ export class UsersEffects {
|
|||||||
room: Room,
|
room: Room,
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
extra: Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof UsersActions.kickUserSuccess> | ReturnType<typeof UsersActions.banUserSuccess>> = []
|
extra: IncomingModerationExtraAction[] = []
|
||||||
) {
|
) {
|
||||||
const actions: Array<
|
const actions: IncomingModerationAction[] = [
|
||||||
ReturnType<typeof RoomsActions.updateRoom>
|
|
||||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
|
||||||
| ReturnType<typeof UsersActions.kickUserSuccess>
|
|
||||||
| ReturnType<typeof UsersActions.banUserSuccess>
|
|
||||||
> = [
|
|
||||||
RoomsActions.updateRoom({
|
RoomsActions.updateRoom({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
changes: this.removeMemberFromRoom(room, targetUserId)
|
changes: this.removeMemberFromRoom(room, targetUserId)
|
||||||
@@ -460,7 +456,10 @@ export class UsersEffects {
|
|||||||
return currentRoom?.id === room.id;
|
return currentRoom?.id === room.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private canForgetForTarget(targetUserId: string, currentUser: User | null): ReturnType<typeof RoomsActions.forgetRoom> | null {
|
private canForgetForTarget(
|
||||||
|
targetUserId: string,
|
||||||
|
currentUser: User | null
|
||||||
|
): ReturnType<typeof RoomsActions.forgetRoom> | null {
|
||||||
return this.isCurrentUserTarget(targetUserId, currentUser)
|
return this.isCurrentUserTarget(targetUserId, currentUser)
|
||||||
? RoomsActions.forgetRoom({ roomId: '' })
|
? RoomsActions.forgetRoom({ roomId: '' })
|
||||||
: null;
|
: null;
|
||||||
@@ -470,7 +469,7 @@ export class UsersEffects {
|
|||||||
return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId);
|
return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildIncomingBan(event: any, targetUserId: string, roomId: string): BanEntry {
|
private buildIncomingBan(event: ChatEvent, targetUserId: string, roomId: string): BanEntry {
|
||||||
const payloadBan = event.ban && typeof event.ban === 'object'
|
const payloadBan = event.ban && typeof event.ban === 'object'
|
||||||
? event.ban as Partial<BanEntry>
|
? event.ban as Partial<BanEntry>
|
||||||
: null;
|
: null;
|
||||||
@@ -500,7 +499,7 @@ export class UsersEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleIncomingKick(
|
private handleIncomingKick(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentUser: User | null,
|
currentUser: User | null,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[]
|
savedRooms: Room[]
|
||||||
@@ -518,15 +517,17 @@ export class UsersEffects {
|
|||||||
currentRoom,
|
currentRoom,
|
||||||
this.isCurrentUserTarget(targetUserId, currentUser)
|
this.isCurrentUserTarget(targetUserId, currentUser)
|
||||||
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
||||||
: [UsersActions.kickUserSuccess({ userId: targetUserId,
|
: [
|
||||||
roomId: room.id })]
|
UsersActions.kickUserSuccess({ userId: targetUserId,
|
||||||
|
roomId: room.id })
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIncomingBan(
|
private handleIncomingBan(
|
||||||
event: any,
|
event: ChatEvent,
|
||||||
currentUser: User | null,
|
currentUser: User | null,
|
||||||
currentRoom: Room | null,
|
currentRoom: Room | null,
|
||||||
savedRooms: Room[]
|
savedRooms: Room[]
|
||||||
@@ -545,9 +546,11 @@ export class UsersEffects {
|
|||||||
currentRoom,
|
currentRoom,
|
||||||
this.isCurrentUserTarget(targetUserId, currentUser)
|
this.isCurrentUserTarget(targetUserId, currentUser)
|
||||||
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
||||||
: [UsersActions.banUserSuccess({ userId: targetUserId,
|
: [
|
||||||
roomId: room.id,
|
UsersActions.banUserSuccess({ userId: targetUserId,
|
||||||
ban })]
|
roomId: room.id,
|
||||||
|
ban })
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return from(this.db.saveBan(ban)).pipe(
|
return from(this.db.saveBan(ban)).pipe(
|
||||||
@@ -556,7 +559,7 @@ export class UsersEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIncomingUnban(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
private handleIncomingUnban(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const banOderId = typeof event.banOderId === 'string'
|
const banOderId = typeof event.banOderId === 'string'
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return usersAdapter.updateOne(
|
return usersAdapter.updateOne(
|
||||||
{
|
{
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
Reference in New Issue
Block a user