Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3ef8e8800 | |||
| c862c2fe03 | |||
| 4faa62864d | |||
| 1cdd1c5d2b | |||
| 141de64767 | |||
| eb987ac672 |
58
electron/app/auto-start.ts
Normal file
58
electron/app/auto-start.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import AutoLaunch from 'auto-launch';
|
||||||
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
|
||||||
|
let autoLauncher: AutoLaunch | null = null;
|
||||||
|
|
||||||
|
function resolveLaunchPath(): string {
|
||||||
|
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
||||||
|
const appImagePath = process.platform === 'linux'
|
||||||
|
? String(process.env['APPIMAGE'] || '').trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return appImagePath || process.execPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoLauncher(): AutoLaunch | null {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoLauncher) {
|
||||||
|
autoLauncher = new AutoLaunch({
|
||||||
|
name: app.getName(),
|
||||||
|
path: resolveLaunchPath()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return autoLauncher;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
||||||
|
const launcher = getAutoLauncher();
|
||||||
|
|
||||||
|
if (!launcher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentlyEnabled = await launcher.isEnabled();
|
||||||
|
|
||||||
|
if (currentlyEnabled === enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await launcher.enable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await launcher.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synchronizeAutoStartSetting(enabled = readDesktopSettings().autoStart): Promise<void> {
|
||||||
|
try {
|
||||||
|
await setAutoStartEnabled(enabled);
|
||||||
|
} catch {
|
||||||
|
// Auto-launch integration should never block app startup or settings saves.
|
||||||
|
}
|
||||||
|
}
|
||||||
121
electron/app/deep-links.ts
Normal file
121
electron/app/deep-links.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { createWindow, getMainWindow } from '../window/create-window';
|
||||||
|
|
||||||
|
const CUSTOM_PROTOCOL = 'toju';
|
||||||
|
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||||
|
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||||
|
|
||||||
|
let pendingDeepLink: string | null = null;
|
||||||
|
|
||||||
|
function resolveDevSingleInstanceExitCode(): number | null {
|
||||||
|
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
|
|
||||||
|
return Number.isInteger(parsedValue) && parsedValue > 0
|
||||||
|
? parsedValue
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDeepLink(argv: string[]): string | null {
|
||||||
|
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusMainWindow(): void {
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardDeepLink(url: string): void {
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoadingMainFrame()) {
|
||||||
|
pendingDeepLink = url;
|
||||||
|
|
||||||
|
if (app.isReady() && (!mainWindow || mainWindow.isDestroyed())) {
|
||||||
|
void createWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusMainWindow();
|
||||||
|
mainWindow.webContents.send('deep-link-received', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerProtocolClient(): void {
|
||||||
|
if (process.defaultApp) {
|
||||||
|
const appEntrypoint = process.argv[1];
|
||||||
|
|
||||||
|
if (appEntrypoint) {
|
||||||
|
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(appEntrypoint)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeDeepLinkHandling(): boolean {
|
||||||
|
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
if (!hasSingleInstanceLock) {
|
||||||
|
const devExitCode = resolveDevSingleInstanceExitCode();
|
||||||
|
|
||||||
|
if (devExitCode != null) {
|
||||||
|
app.exit(devExitCode);
|
||||||
|
} else {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProtocolClient();
|
||||||
|
|
||||||
|
const initialDeepLink = extractDeepLink(process.argv);
|
||||||
|
|
||||||
|
if (initialDeepLink) {
|
||||||
|
pendingDeepLink = initialDeepLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
focusMainWindow();
|
||||||
|
|
||||||
|
const deepLink = extractDeepLink(argv);
|
||||||
|
|
||||||
|
if (deepLink) {
|
||||||
|
forwardDeepLink(deepLink);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('open-url', (event, url) => {
|
||||||
|
event.preventDefault();
|
||||||
|
forwardDeepLink(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePendingDeepLink(): string | null {
|
||||||
|
const deepLink = pendingDeepLink;
|
||||||
|
|
||||||
|
pendingDeepLink = null;
|
||||||
|
|
||||||
|
return deepLink;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||||
|
import { synchronizeAutoStartSetting } from './auto-start';
|
||||||
import {
|
import {
|
||||||
initializeDatabase,
|
initializeDatabase,
|
||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
@@ -24,6 +25,7 @@ export function registerAppLifecycle(): void {
|
|||||||
setupCqrsHandlers();
|
setupCqrsHandlers();
|
||||||
setupWindowControlHandlers();
|
setupWindowControlHandlers();
|
||||||
setupSystemHandlers();
|
setupSystemHandlers();
|
||||||
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
topic: room.topic ?? null,
|
topic: room.topic ?? null,
|
||||||
hostId: room.hostId,
|
hostId: room.hostId,
|
||||||
password: room.password ?? null,
|
password: room.password ?? null,
|
||||||
|
hasPassword: room.hasPassword ? 1 : 0,
|
||||||
isPrivate: room.isPrivate ? 1 : 0,
|
isPrivate: room.isPrivate ? 1 : 0,
|
||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
userCount: room.userCount ?? 0,
|
userCount: room.userCount ?? 0,
|
||||||
@@ -20,7 +21,10 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
||||||
members: room.members != null ? JSON.stringify(room.members) : null
|
members: room.members != null ? JSON.stringify(room.members) : null,
|
||||||
|
sourceId: room.sourceId ?? null,
|
||||||
|
sourceName: room.sourceName ?? null,
|
||||||
|
sourceUrl: room.sourceUrl ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from './utils/applyUpdates';
|
} from './utils/applyUpdates';
|
||||||
|
|
||||||
const ROOM_TRANSFORMS: TransformMap = {
|
const ROOM_TRANSFORMS: TransformMap = {
|
||||||
|
hasPassword: boolToInt,
|
||||||
isPrivate: boolToInt,
|
isPrivate: boolToInt,
|
||||||
userCount: (val) => (val ?? 0),
|
userCount: (val) => (val ?? 0),
|
||||||
permissions: jsonOrNull,
|
permissions: jsonOrNull,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
topic: row.topic ?? undefined,
|
topic: row.topic ?? undefined,
|
||||||
hostId: row.hostId,
|
hostId: row.hostId,
|
||||||
password: row.password ?? undefined,
|
password: row.password ?? undefined,
|
||||||
|
hasPassword: !!row.hasPassword,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
userCount: row.userCount,
|
userCount: row.userCount,
|
||||||
@@ -65,7 +66,10 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||||
members: row.members ? JSON.parse(row.members) : undefined
|
members: row.members ? JSON.parse(row.members) : undefined,
|
||||||
|
sourceId: row.sourceId ?? undefined,
|
||||||
|
sourceName: row.sourceName ?? undefined,
|
||||||
|
sourceUrl: row.sourceUrl ?? undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface RoomPayload {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
@@ -93,6 +94,9 @@ export interface RoomPayload {
|
|||||||
permissions?: unknown;
|
permissions?: unknown;
|
||||||
channels?: unknown[];
|
channels?: unknown[];
|
||||||
members?: unknown[];
|
members?: unknown[];
|
||||||
|
sourceId?: string;
|
||||||
|
sourceName?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanPayload {
|
export interface BanPayload {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
|||||||
|
|
||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
|
autoStart: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -19,6 +20,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
|
|
||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
|
autoStart: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -81,6 +83,9 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||||
|
autoStart: typeof parsed.autoStart === 'boolean'
|
||||||
|
? parsed.autoStart
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||||
? parsed.vaapiVideoEncode
|
? parsed.vaapiVideoEncode
|
||||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||||
@@ -102,6 +107,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
};
|
};
|
||||||
const nextSettings: DesktopSettings = {
|
const nextSettings: DesktopSettings = {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||||
|
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||||
|
? mergedSettings.autoStart
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export class RoomEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
password!: string | null;
|
password!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { default: 0 })
|
||||||
|
hasPassword!: number;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
@@ -50,4 +53,13 @@ export class RoomEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
members!: string | null;
|
members!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceUrl!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
restartToApplyUpdate,
|
restartToApplyUpdate,
|
||||||
type DesktopUpdateServerContext
|
type DesktopUpdateServerContext
|
||||||
} from '../update/desktop-updater';
|
} from '../update/desktop-updater';
|
||||||
|
import { consumePendingDeepLink } from '../app/deep-links';
|
||||||
|
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -258,6 +260,8 @@ export function setupSystemHandlers(): void {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
||||||
|
|
||||||
ipcMain.handle('get-sources', async () => {
|
ipcMain.handle('get-sources', async () => {
|
||||||
try {
|
try {
|
||||||
const thumbnailSize = { width: 240, height: 150 };
|
const thumbnailSize = { width: 240, height: 150 };
|
||||||
@@ -326,6 +330,7 @@ export function setupSystemHandlers(): void {
|
|||||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { initializeDeepLinkHandling } from './app/deep-links';
|
||||||
import { configureAppFlags } from './app/flags';
|
import { configureAppFlags } from './app/flags';
|
||||||
import { registerAppLifecycle } from './app/lifecycle';
|
import { registerAppLifecycle } from './app/lifecycle';
|
||||||
|
|
||||||
configureAppFlags();
|
configureAppFlags();
|
||||||
|
|
||||||
|
if (initializeDeepLinkHandling()) {
|
||||||
registerAppLifecycle();
|
registerAppLifecycle();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
|
||||||
|
name = 'AddRoomSourceAndPasswordState1000000000002';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "rooms"
|
||||||
|
SET "hasPassword" = CASE
|
||||||
|
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Command, Query } from './cqrs/types';
|
|||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
|
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -115,8 +116,10 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
getDesktopSettings: () => Promise<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -130,12 +133,14 @@ export interface ElectronAPI {
|
|||||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||||
setDesktopSettings: (patch: {
|
setDesktopSettings: (patch: {
|
||||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||||
|
autoStart?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
vaapiVideoEncode?: boolean;
|
vaapiVideoEncode?: boolean;
|
||||||
}) => Promise<{
|
}) => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -143,6 +148,7 @@ export interface ElectronAPI {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
@@ -198,6 +204,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
|
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||||
@@ -216,6 +223,17 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||||
|
onDeepLinkReceived: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
||||||
|
listener(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
|
|||||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -45,11 +46,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@angular/build": "^21.0.4",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.0.4",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -10816,6 +10818,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/auto-launch": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-/nGvQZSzM/pvCMCh4Gt2kIeiUmOP/cKGJbjlInI+A+5MoV/7XmT56DJ6EU8bqc3+ItxEe4UC2GVspmPzcCc8cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -12875,6 +12884,11 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/applescript": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -12968,6 +12982,22 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/auto-launch": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"applescript": "^1.0.0",
|
||||||
|
"mkdirp": "^0.5.1",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"untildify": "^3.0.2",
|
||||||
|
"winreg": "1.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.23",
|
"version": "10.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
@@ -22285,9 +22315,7 @@
|
|||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -23745,7 +23773,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -29571,6 +29598,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/untildify": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/upath": {
|
"node_modules/upath": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
||||||
@@ -31161,6 +31197,12 @@
|
|||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/winreg": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -70,6 +70,7 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -120,6 +122,14 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.metoyou.app",
|
"appId": "com.metoyou.app",
|
||||||
"productName": "MetoYou",
|
"productName": "MetoYou",
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"name": "Toju Invite Links",
|
||||||
|
"schemes": [
|
||||||
|
"toju"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
|
|||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.set('trust proxy', true);
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity, JoinRequestEntity } from '../../../entities';
|
import {
|
||||||
|
ServerEntity,
|
||||||
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
|
} from '../../../entities';
|
||||||
import { DeleteServerCommand } from '../../types';
|
import { DeleteServerCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { serverId } = command.payload;
|
const { serverId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
|||||||
description: server.description ?? null,
|
description: server.description ?? null,
|
||||||
ownerId: server.ownerId,
|
ownerId: server.ownerId,
|
||||||
ownerPublicKey: server.ownerPublicKey,
|
ownerPublicKey: server.ownerPublicKey,
|
||||||
|
passwordHash: server.passwordHash ?? null,
|
||||||
isPrivate: server.isPrivate ? 1 : 0,
|
isPrivate: server.isPrivate ? 1 : 0,
|
||||||
maxUsers: server.maxUsers,
|
maxUsers: server.maxUsers,
|
||||||
currentUsers: server.currentUsers,
|
currentUsers: server.currentUsers,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
|||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
ownerId: row.ownerId,
|
ownerId: row.ownerId,
|
||||||
ownerPublicKey: row.ownerPublicKey,
|
ownerPublicKey: row.ownerPublicKey,
|
||||||
|
hasPassword: !!row.passwordHash,
|
||||||
|
passwordHash: row.passwordHash ?? undefined,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
maxUsers: row.maxUsers,
|
maxUsers: row.maxUsers,
|
||||||
currentUsers: row.currentUsers,
|
currentUsers: row.currentUsers,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface ServerPayload {
|
|||||||
description?: string;
|
description?: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerPublicKey: string;
|
ownerPublicKey: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
|
passwordHash?: string | null;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
currentUsers: number;
|
currentUsers: number;
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { DataSource } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||||
@@ -51,7 +54,10 @@ export async function initDatabase(): Promise<void> {
|
|||||||
entities: [
|
entities: [
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_bans')
|
||||||
|
export class ServerBanEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
bannedBy!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
displayName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
reason!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
expiresAt!: number | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ export class ServerEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
ownerPublicKey!: string;
|
ownerPublicKey!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
passwordHash!: string | null;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
|
|||||||
29
server/src/entities/ServerInviteEntity.ts
Normal file
29
server/src/entities/ServerInviteEntity.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_invites')
|
||||||
|
export class ServerInviteEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
createdBy!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
createdByDisplayName!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('integer')
|
||||||
|
expiresAt!: number;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerMembershipEntity.ts
Normal file
26
server/src/entities/ServerMembershipEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_memberships')
|
||||||
|
export class ServerMembershipEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
joinedAt!: number;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
lastAccessAt!: number;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
export { AuthUserEntity } from './AuthUserEntity';
|
export { AuthUserEntity } from './AuthUserEntity';
|
||||||
export { ServerEntity } from './ServerEntity';
|
export { ServerEntity } from './ServerEntity';
|
||||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||||
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
|
|||||||
@@ -97,12 +97,17 @@ async function bootstrap(): Promise<void> {
|
|||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||||
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||||
|
const localHostNames = [
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1'
|
||||||
|
];
|
||||||
|
|
||||||
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||||
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||||
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||||
|
|
||||||
if (serverProtocol === 'https' && serverHost && !['localhost', '127.0.0.1', '::1'].includes(serverHost)) {
|
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
|
||||||
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class ServerAccessControl1000000000001 implements MigrationInterface {
|
||||||
|
name = 'ServerAccessControl1000000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "passwordHash" TEXT`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_memberships" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"joinedAt" INTEGER NOT NULL,
|
||||||
|
"lastAccessAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_serverId" ON "server_memberships" ("serverId")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_userId" ON "server_memberships" ("userId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_invites" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"createdByDisplayName" TEXT,
|
||||||
|
"createdAt" INTEGER NOT NULL,
|
||||||
|
"expiresAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_serverId" ON "server_invites" ("serverId")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_expiresAt" ON "server_invites" ("expiresAt")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_bans" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"bannedBy" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"reason" TEXT,
|
||||||
|
"expiresAt" INTEGER,
|
||||||
|
"createdAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_serverId" ON "server_bans" ("serverId")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_userId" ON "server_bans" ("userId")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_bans"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_invites"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_memberships"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "passwordHash"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||||
|
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||||
|
|
||||||
export const serverMigrations = [InitialSchema1000000000000];
|
export const serverMigrations = [
|
||||||
|
InitialSchema1000000000000,
|
||||||
|
ServerAccessControl1000000000001
|
||||||
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import proxyRouter from './proxy';
|
|||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
import joinRequestsRouter from './join-requests';
|
import joinRequestsRouter from './join-requests';
|
||||||
|
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||||
|
|
||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
@@ -12,5 +13,7 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
app.use('/api/invites', invitesApiRouter);
|
||||||
app.use('/api/requests', joinRequestsRouter);
|
app.use('/api/requests', joinRequestsRouter);
|
||||||
|
app.use('/invite', invitePageRouter);
|
||||||
}
|
}
|
||||||
|
|||||||
57
server/src/routes/invite-utils.ts
Normal file
57
server/src/routes/invite-utils.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
function buildOrigin(protocol: string, host: string): string {
|
||||||
|
return `${protocol}://${host}`.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function originFromUrl(url: URL): string {
|
||||||
|
return buildOrigin(url.protocol.replace(':', ''), url.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestOrigin(request: Request): string {
|
||||||
|
const forwardedProtoHeader = request.get('x-forwarded-proto');
|
||||||
|
const forwardedHostHeader = request.get('x-forwarded-host');
|
||||||
|
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
|
||||||
|
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
|
||||||
|
|
||||||
|
return buildOrigin(protocol, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveWebAppOrigin(signalOrigin: string): string {
|
||||||
|
const url = new URL(signalOrigin);
|
||||||
|
|
||||||
|
if (url.hostname === 'signal.toju.app' && !url.port) {
|
||||||
|
return 'https://web.toju.app';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.hostname.startsWith('signal.')) {
|
||||||
|
url.hostname = url.hostname.replace(/^signal\./, 'web.');
|
||||||
|
|
||||||
|
if (url.port === '3001') {
|
||||||
|
url.port = '4200';
|
||||||
|
}
|
||||||
|
|
||||||
|
return originFromUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.port === '3001') {
|
||||||
|
url.port = '4200';
|
||||||
|
return originFromUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://web.toju.app';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
const browserOrigin = deriveWebAppOrigin(signalOrigin);
|
||||||
|
|
||||||
|
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||||
|
}
|
||||||
331
server/src/routes/invites.ts
Normal file
331
server/src/routes/invites.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getUserById } from '../cqrs';
|
||||||
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
import { getActiveServerInvite } from '../services/server-access.service';
|
||||||
|
import {
|
||||||
|
buildAppInviteUrl,
|
||||||
|
buildBrowserInviteUrl,
|
||||||
|
buildInviteUrl,
|
||||||
|
getRequestOrigin
|
||||||
|
} from './invite-utils';
|
||||||
|
|
||||||
|
export const invitesApiRouter = Router();
|
||||||
|
export const invitePageRouter = Router();
|
||||||
|
|
||||||
|
async function enrichServer(server: ServerPayload, sourceUrl: string) {
|
||||||
|
const owner = await getUserById(server.ownerId);
|
||||||
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...publicServer,
|
||||||
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
|
ownerName: owner?.displayName,
|
||||||
|
sourceUrl,
|
||||||
|
userCount: server.currentUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvitePage(options: {
|
||||||
|
appUrl?: string;
|
||||||
|
browserUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
inviteUrl?: string;
|
||||||
|
isExpired: boolean;
|
||||||
|
ownerName?: string;
|
||||||
|
serverDescription?: string;
|
||||||
|
serverName: string;
|
||||||
|
}) {
|
||||||
|
const expiryLabel = options.expiresAt
|
||||||
|
? new Date(options.expiresAt).toLocaleString('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const statusLabel = options.isExpired ? 'Expired' : 'Active';
|
||||||
|
const statusColor = options.isExpired ? '#f87171' : '#4ade80';
|
||||||
|
const buttonOpacity = options.isExpired ? 'opacity:0.5;pointer-events:none;' : '';
|
||||||
|
const errorBlock = options.error
|
||||||
|
? `<div class="notice notice-error">${options.error}</div>`
|
||||||
|
: '';
|
||||||
|
const description = options.serverDescription
|
||||||
|
? `<p class="description">${options.serverDescription}</p>`
|
||||||
|
: '<p class="description">You have been invited to join a Toju server.</p>';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Invite to ${options.serverName}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #050816;
|
||||||
|
--bg-soft: rgba(11, 18, 42, 0.78);
|
||||||
|
--card: rgba(15, 23, 42, 0.92);
|
||||||
|
--border: rgba(148, 163, 184, 0.18);
|
||||||
|
--text: #f8fafc;
|
||||||
|
--muted: #cbd5e1;
|
||||||
|
--primary: #8b5cf6;
|
||||||
|
--primary-soft: rgba(139, 92, 246, 0.16);
|
||||||
|
--secondary: rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.28), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(139, 92, 246, 0.24), transparent 30%),
|
||||||
|
linear-gradient(180deg, #050816 0%, #0b1120 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 36px 36px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.8), rgba(15, 23, 42, 0.55));
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: ${statusColor};
|
||||||
|
box-shadow: 0 0 0 6px color-mix(in srgb, ${statusColor} 18%, transparent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: clamp(2rem, 3vw, 3.25rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 28px 36px 36px;
|
||||||
|
}
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.meta-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||||||
|
box-shadow: 0 18px 36px rgba(99, 102, 241, 0.28);
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.notice-error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.32);
|
||||||
|
background: rgba(127, 29, 29, 0.18);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #c4b5fd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
color: #ddd6fe;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero, .content { padding-inline: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="eyebrow"><span class="status-dot"></span>${statusLabel} invite</div>
|
||||||
|
<h1>Join ${options.serverName}</h1>
|
||||||
|
${description}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
${errorBlock}
|
||||||
|
<div class="meta-grid">
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Server</div>
|
||||||
|
<div class="meta-value">${options.serverName}</div>
|
||||||
|
</article>
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Owner</div>
|
||||||
|
<div class="meta-value">${options.ownerName || 'Unknown'}</div>
|
||||||
|
</article>
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Expires</div>
|
||||||
|
<div class="meta-value">${expiryLabel || 'Expired'}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" style="${buttonOpacity}">
|
||||||
|
<a class="button button-primary" href="${options.browserUrl || '#'}">Join in browser</a>
|
||||||
|
<a class="button button-secondary" href="${options.appUrl || '#'}">Open with Toju</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
Invite links bypass private and password restrictions, but banned users still cannot join.
|
||||||
|
If Toju is not installed yet, use the desktop button after installing from <a href="https://toju.app/downloads">toju.app/downloads</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Share link: <code>${options.inviteUrl || 'Unavailable'}</code></span>
|
||||||
|
<a href="https://toju.app/downloads">Download Toju</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
invitesApiRouter.get('/:id', async (req, res) => {
|
||||||
|
const signalOrigin = getRequestOrigin(req);
|
||||||
|
const bundle = await getActiveServerInvite(req.params['id']);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
|
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = rowToServer(bundle.server);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: bundle.invite.id,
|
||||||
|
serverId: bundle.invite.serverId,
|
||||||
|
createdAt: bundle.invite.createdAt,
|
||||||
|
expiresAt: bundle.invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
sourceUrl: signalOrigin,
|
||||||
|
createdBy: bundle.invite.createdBy,
|
||||||
|
createdByDisplayName: bundle.invite.createdByDisplayName ?? undefined,
|
||||||
|
isExpired: bundle.invite.expiresAt <= Date.now(),
|
||||||
|
server: await enrichServer(server, signalOrigin)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
invitePageRouter.get('/:id', async (req, res) => {
|
||||||
|
const signalOrigin = getRequestOrigin(req);
|
||||||
|
const bundle = await getActiveServerInvite(req.params['id']);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
|
res.status(404).send(renderInvitePage({
|
||||||
|
error: 'This invite has expired or is no longer available.',
|
||||||
|
isExpired: true,
|
||||||
|
serverName: 'Toju server'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = rowToServer(bundle.server);
|
||||||
|
const owner = await getUserById(server.ownerId);
|
||||||
|
|
||||||
|
res.send(renderInvitePage({
|
||||||
|
serverName: server.name,
|
||||||
|
serverDescription: server.description,
|
||||||
|
ownerName: owner?.displayName,
|
||||||
|
expiresAt: bundle.invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
isExpired: bundle.invite.expiresAt <= Date.now()
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -1,29 +1,90 @@
|
|||||||
import { Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
import { ServerPayload } from '../cqrs/types';
|
||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
getUserById,
|
getUserById,
|
||||||
upsertServer,
|
upsertServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
createJoinRequest,
|
|
||||||
getPendingRequestsForServer
|
getPendingRequestsForServer
|
||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import { notifyServerOwner } from '../websocket/broadcast';
|
import {
|
||||||
|
banServerUser,
|
||||||
|
buildSignalingUrl,
|
||||||
|
createServerInvite,
|
||||||
|
joinServerWithAccess,
|
||||||
|
leaveServerUser,
|
||||||
|
passwordHashForInput,
|
||||||
|
ServerAccessError,
|
||||||
|
kickServerUser,
|
||||||
|
ensureServerMembership,
|
||||||
|
unbanServerUser
|
||||||
|
} from '../services/server-access.service';
|
||||||
|
import {
|
||||||
|
buildAppInviteUrl,
|
||||||
|
buildBrowserInviteUrl,
|
||||||
|
buildInviteUrl,
|
||||||
|
getRequestOrigin
|
||||||
|
} from './invite-utils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
async function enrichServer(server: ServerPayload) {
|
function normalizeRole(role: unknown): string | null {
|
||||||
|
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
||||||
|
return !!role && allowedRoles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...server,
|
...publicServer,
|
||||||
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
ownerName: owner?.displayName,
|
ownerName: owner?.displayName,
|
||||||
|
sourceUrl,
|
||||||
userCount: server.currentUsers
|
userCount: server.currentUsers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendAccessError(error: unknown, res: Response) {
|
||||||
|
if (error instanceof ServerAccessError) {
|
||||||
|
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled server access error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildInviteResponse(invite: {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
createdByDisplayName: string | null;
|
||||||
|
serverId: string;
|
||||||
|
}, server: ServerPayload, signalOrigin: string) {
|
||||||
|
return {
|
||||||
|
id: invite.id,
|
||||||
|
serverId: invite.serverId,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, invite.id),
|
||||||
|
sourceUrl: signalOrigin,
|
||||||
|
createdBy: invite.createdBy,
|
||||||
|
createdByDisplayName: invite.createdByDisplayName ?? undefined,
|
||||||
|
isExpired: invite.expiresAt <= Date.now(),
|
||||||
|
server: await enrichServer(server, signalOrigin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||||
|
|
||||||
@@ -54,17 +115,30 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
const {
|
||||||
|
id: clientId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
ownerId,
|
||||||
|
ownerPublicKey,
|
||||||
|
isPrivate,
|
||||||
|
maxUsers,
|
||||||
|
password,
|
||||||
|
tags
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!name || !ownerId || !ownerPublicKey)
|
if (!name || !ownerId || !ownerPublicKey)
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
|
||||||
|
const passwordHash = passwordHashForInput(password);
|
||||||
const server: ServerPayload = {
|
const server: ServerPayload = {
|
||||||
id: clientId || uuidv4(),
|
id: clientId || uuidv4(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerPublicKey,
|
ownerPublicKey,
|
||||||
|
hasPassword: !!passwordHash,
|
||||||
|
passwordHash,
|
||||||
isPrivate: isPrivate ?? false,
|
isPrivate: isPrivate ?? false,
|
||||||
maxUsers: maxUsers ?? 0,
|
maxUsers: maxUsers ?? 0,
|
||||||
currentUsers: 0,
|
currentUsers: 0,
|
||||||
@@ -74,25 +148,216 @@ router.post('/', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
res.status(201).json(server);
|
await ensureServerMembership(server.id, ownerId);
|
||||||
|
|
||||||
|
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { currentOwnerId, ...updates } = req.body;
|
const {
|
||||||
|
currentOwnerId,
|
||||||
|
actingRole,
|
||||||
|
password,
|
||||||
|
hasPassword: _ignoredHasPassword,
|
||||||
|
passwordHash: _ignoredPasswordHash,
|
||||||
|
...updates
|
||||||
|
} = req.body;
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||||
|
const normalizedRole = normalizeRole(actingRole);
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (existing.ownerId !== authenticatedOwnerId)
|
if (
|
||||||
|
existing.ownerId !== authenticatedOwnerId &&
|
||||||
|
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
||||||
|
) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||||
|
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||||
|
const server: ServerPayload = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
hasPassword: !!nextPasswordHash,
|
||||||
|
passwordHash: nextPasswordHash,
|
||||||
|
lastSeen: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
res.json(server);
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/join', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { userId, password, inviteId } = req.body;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await joinServerWithAccess({
|
||||||
|
serverId,
|
||||||
|
userId: String(userId),
|
||||||
|
password: typeof password === 'string' ? password : undefined,
|
||||||
|
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
||||||
|
});
|
||||||
|
const origin = getRequestOrigin(req);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
signalingUrl: buildSignalingUrl(origin),
|
||||||
|
joinedBefore: result.joinedBefore,
|
||||||
|
via: result.via,
|
||||||
|
server: await enrichServer(result.server, origin)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendAccessError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/invites', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { requesterUserId, requesterDisplayName } = req.body;
|
||||||
|
|
||||||
|
if (!requesterUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invite = await createServerInvite(
|
||||||
|
serverId,
|
||||||
|
String(requesterUserId),
|
||||||
|
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req)));
|
||||||
|
} catch (error) {
|
||||||
|
sendAccessError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/kick', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, targetUserId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await kickServerUser(serverId, String(targetUserId));
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/ban', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await banServerUser({
|
||||||
|
serverId,
|
||||||
|
userId: String(targetUserId),
|
||||||
|
banId: typeof banId === 'string' ? banId : undefined,
|
||||||
|
bannedBy: String(actorUserId || ''),
|
||||||
|
displayName: typeof displayName === 'string' ? displayName : undefined,
|
||||||
|
reason: typeof reason === 'string' ? reason : undefined,
|
||||||
|
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/unban', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unbanServerUser({
|
||||||
|
serverId,
|
||||||
|
banId: typeof banId === 'string' ? banId : undefined,
|
||||||
|
userId: typeof targetUserId === 'string' ? targetUserId : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/leave', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { userId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await leaveServerUser(serverId, String(userId));
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/heartbeat', async (req, res) => {
|
router.post('/:id/heartbeat', async (req, res) => {
|
||||||
@@ -128,32 +393,6 @@ router.delete('/:id', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/join', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { userId, userPublicKey, displayName } = req.body;
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server)
|
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
|
||||||
|
|
||||||
const request: JoinRequestPayload = {
|
|
||||||
id: uuidv4(),
|
|
||||||
serverId,
|
|
||||||
userId,
|
|
||||||
userPublicKey,
|
|
||||||
displayName,
|
|
||||||
status: server.isPrivate ? 'pending' : 'approved',
|
|
||||||
createdAt: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
await createJoinRequest(request);
|
|
||||||
|
|
||||||
if (server.isPrivate)
|
|
||||||
notifyServerOwner(server.ownerId, { type: 'join_request', request });
|
|
||||||
|
|
||||||
res.status(201).json(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id/requests', async (req, res) => {
|
router.get('/:id/requests', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { ownerId } = req.query;
|
const { ownerId } = req.query;
|
||||||
@@ -170,4 +409,15 @@ router.get('/:id/requests', async (req, res) => {
|
|||||||
res.json({ requests });
|
res.json({ requests });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const server = await getServerById(id);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
390
server/src/services/server-access.service.ts
Normal file
390
server/src/services/server-access.service.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import {
|
||||||
|
ServerBanEntity,
|
||||||
|
ServerEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerMembershipEntity
|
||||||
|
} from '../entities';
|
||||||
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
|
||||||
|
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type JoinAccessVia = 'membership' | 'password' | 'invite' | 'public';
|
||||||
|
|
||||||
|
export interface JoinServerAccessResult {
|
||||||
|
joinedBefore: boolean;
|
||||||
|
server: ServerPayload;
|
||||||
|
via: JoinAccessVia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BanServerUserOptions {
|
||||||
|
banId?: string;
|
||||||
|
bannedBy: string;
|
||||||
|
displayName?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
reason?: string;
|
||||||
|
serverId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerAccessError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ServerAccessError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerRepository() {
|
||||||
|
return getDataSource().getRepository(ServerEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMembershipRepository() {
|
||||||
|
return getDataSource().getRepository(ServerMembershipEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInviteRepository() {
|
||||||
|
return getDataSource().getRepository(ServerInviteEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBanRepository() {
|
||||||
|
return getDataSource().getRepository(ServerBanEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePassword(password?: string | null): string | null {
|
||||||
|
const normalized = password?.trim() ?? '';
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServerOwner(server: ServerEntity, userId: string): boolean {
|
||||||
|
return server.ownerId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashServerPassword(password: string): string {
|
||||||
|
return crypto.createHash('sha256').update(password)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordHashForInput(password?: string | null): string | null {
|
||||||
|
const normalized = normalizePassword(password);
|
||||||
|
|
||||||
|
return normalized ? hashServerPassword(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSignalingUrl(origin: string): string {
|
||||||
|
return origin.replace(/^http/i, 'ws');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneExpiredServerAccessArtifacts(now: number = Date.now()): Promise<void> {
|
||||||
|
await getInviteRepository()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await getBanRepository()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt IS NOT NULL AND expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerRecord(serverId: string): Promise<ServerEntity | null> {
|
||||||
|
return await getServerRepository().findOne({ where: { id: serverId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveServerBan(serverId: string, userId: string): Promise<ServerBanEntity | null> {
|
||||||
|
const banRepo = getBanRepository();
|
||||||
|
const ban = await banRepo.findOne({ where: { serverId, userId } });
|
||||||
|
|
||||||
|
if (!ban)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (ban.expiresAt && ban.expiresAt <= Date.now()) {
|
||||||
|
await banRepo.delete({ id: ban.id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ban;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isServerUserBanned(serverId: string, userId: string): Promise<boolean> {
|
||||||
|
return !!(await getActiveServerBan(serverId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity | null> {
|
||||||
|
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||||
|
const repo = getMembershipRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await repo.findOne({ where: { serverId, userId } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.lastAccessAt = now;
|
||||||
|
await repo.save(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = repo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
serverId,
|
||||||
|
userId,
|
||||||
|
joinedAt: now,
|
||||||
|
lastAccessAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServerMembership(serverId: string, userId: string): Promise<void> {
|
||||||
|
await getMembershipRepository().delete({ serverId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise<ServerEntity> {
|
||||||
|
const server = await getServerRecord(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(serverId, requesterUserId)) {
|
||||||
|
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot create invites');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(serverId, requesterUserId);
|
||||||
|
|
||||||
|
if (server.ownerId !== requesterUserId && !membership) {
|
||||||
|
throw new ServerAccessError(403, 'NOT_MEMBER', 'Only joined users can create invites');
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServerInvite(
|
||||||
|
serverId: string,
|
||||||
|
createdBy: string,
|
||||||
|
createdByDisplayName?: string
|
||||||
|
): Promise<ServerInviteEntity> {
|
||||||
|
await assertCanCreateInvite(serverId, createdBy);
|
||||||
|
|
||||||
|
const repo = getInviteRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const invite = repo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
serverId,
|
||||||
|
createdBy,
|
||||||
|
createdByDisplayName: createdByDisplayName ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + SERVER_INVITE_EXPIRY_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(invite);
|
||||||
|
return invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveServerInvite(
|
||||||
|
inviteId: string
|
||||||
|
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.expiresAt <= Date.now()) {
|
||||||
|
await getInviteRepository().delete({ id: invite.id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await getServerRecord(invite.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invite, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinServerWithAccess(options: {
|
||||||
|
inviteId?: string;
|
||||||
|
password?: string;
|
||||||
|
serverId: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<JoinServerAccessResult> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const server = await getServerRecord(options.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(server.id, options.userId)) {
|
||||||
|
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, options.userId)) {
|
||||||
|
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: !!existingMembership,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'membership'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.inviteId) {
|
||||||
|
const inviteBundle = await getActiveServerInvite(options.inviteId);
|
||||||
|
|
||||||
|
if (!inviteBundle || inviteBundle.server.id !== server.id) {
|
||||||
|
throw new ServerAccessError(410, 'INVITE_EXPIRED', 'Invite link has expired or is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: !!existingMembership,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'invite'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: true,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'membership'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.passwordHash) {
|
||||||
|
const passwordHash = passwordHashForInput(options.password);
|
||||||
|
|
||||||
|
if (!passwordHash || passwordHash !== server.passwordHash) {
|
||||||
|
throw new ServerAccessError(403, 'PASSWORD_REQUIRED', 'Password required to join this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: false,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'password'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.isPrivate) {
|
||||||
|
throw new ServerAccessError(403, 'PRIVATE_SERVER', 'Private servers require an invite link');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: false,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'public'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeWebSocketJoin(serverId: string, userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const server = await getServerRecord(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return { allowed: false,
|
||||||
|
reason: 'SERVER_NOT_FOUND' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(serverId, userId)) {
|
||||||
|
return { allowed: false,
|
||||||
|
reason: 'BANNED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, userId)) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(serverId, userId);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.isPrivate && !server.passwordHash) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: server.isPrivate ? 'PRIVATE_SERVER' : 'PASSWORD_REQUIRED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kickServerUser(serverId: string, userId: string): Promise<void> {
|
||||||
|
await removeServerMembership(serverId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveServerUser(serverId: string, userId: string): Promise<void> {
|
||||||
|
await removeServerMembership(serverId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
|
||||||
|
await removeServerMembership(options.serverId, options.userId);
|
||||||
|
|
||||||
|
const repo = getBanRepository();
|
||||||
|
const existing = await repo.findOne({ where: { serverId: options.serverId, userId: options.userId } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await repo.delete({ id: existing.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = repo.create({
|
||||||
|
id: options.banId ?? uuidv4(),
|
||||||
|
serverId: options.serverId,
|
||||||
|
userId: options.userId,
|
||||||
|
bannedBy: options.bannedBy,
|
||||||
|
displayName: options.displayName ?? null,
|
||||||
|
reason: options.reason ?? null,
|
||||||
|
expiresAt: options.expiresAt ?? null,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanServerUser(options: { banId?: string; serverId: string; userId?: string }): Promise<void> {
|
||||||
|
const repo = getBanRepository();
|
||||||
|
|
||||||
|
if (options.banId) {
|
||||||
|
await repo.delete({ id: options.banId, serverId: options.serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.userId) {
|
||||||
|
await repo.delete({ serverId: options.serverId, userId: options.userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||||
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -23,8 +24,24 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
|||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const sid = String(message['serverId']);
|
const sid = String(message['serverId']);
|
||||||
|
|
||||||
|
if (!sid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
||||||
|
|
||||||
|
if (!authorization.allowed) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'access_denied',
|
||||||
|
serverId: sid,
|
||||||
|
reason: authorization.reason
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isNew = !user.serverIds.has(sid);
|
const isNew = !user.serverIds.has(sid);
|
||||||
|
|
||||||
user.serverIds.add(sid);
|
user.serverIds.add(sid);
|
||||||
@@ -71,7 +88,8 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: user.displayName ?? 'Anonymous',
|
||||||
serverId: leaveSid
|
serverId: leaveSid,
|
||||||
|
serverIds: Array.from(user.serverIds)
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +139,7 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWebSocketMessage(connectionId: string, message: WsMessage): void {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
@@ -133,7 +151,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'join_server':
|
case 'join_server':
|
||||||
handleJoinServer(user, message, connectionId);
|
await handleJoinServer(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'view_server':
|
case 'view_server':
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
serverId: sid
|
serverId: sid,
|
||||||
|
serverIds: []
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,11 +78,11 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', async (data) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
|
|
||||||
handleWebSocketMessage(connectionId, message);
|
await handleWebSocketMessage(connectionId, message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,47 +50,6 @@
|
|||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (desktopUpdateState().serverBlocked) {
|
|
||||||
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
|
|
||||||
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
|
|
||||||
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
|
|
||||||
<p class="mt-3 text-sm text-muted-foreground">
|
|
||||||
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
|
|
||||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
|
|
||||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="refreshDesktopUpdateContext()"
|
|
||||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="openNetworkSettings()"
|
|
||||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Open network settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
<!-- Unified Settings Modal -->
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'invite/:inviteId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/invite/invite.component').then((module) => module.InviteComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'search',
|
path: 'search',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
102
src/app/app.ts
102
src/app/app.ts
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -35,6 +36,15 @@ import {
|
|||||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||||
} from './core/constants';
|
} from './core/constants';
|
||||||
|
|
||||||
|
interface DeepLinkElectronApi {
|
||||||
|
consumePendingDeepLink?: () => Promise<string | null>;
|
||||||
|
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeepLinkWindow = Window & {
|
||||||
|
electronAPI?: DeepLinkElectronApi;
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -50,7 +60,7 @@ import {
|
|||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit, OnDestroy {
|
||||||
store = inject(Store);
|
store = inject(Store);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
@@ -63,6 +73,7 @@ export class App implements OnInit {
|
|||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
private externalLinks = inject(ExternalLinkService);
|
private externalLinks = inject(ExternalLinkService);
|
||||||
|
private deepLinkCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
@@ -80,6 +91,8 @@ export class App implements OnInit {
|
|||||||
await this.timeSync.syncWithEndpoint(apiBase);
|
await this.timeSync.syncWithEndpoint(apiBase);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
await this.setupDesktopDeepLinks();
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
@@ -87,8 +100,12 @@ export class App implements OnInit {
|
|||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
if (!this.isPublicRoute(this.router.url)) {
|
||||||
this.router.navigate(['/login']).catch(() => {});
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
returnUrl: this.router.url
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||||
@@ -116,6 +133,11 @@ export class App implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.deepLinkCleanup?.();
|
||||||
|
this.deepLinkCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
openNetworkSettings(): void {
|
openNetworkSettings(): void {
|
||||||
this.settingsModal.open('network');
|
this.settingsModal.open('network');
|
||||||
}
|
}
|
||||||
@@ -131,4 +153,78 @@ export class App implements OnInit {
|
|||||||
async restartToApplyUpdate(): Promise<void> {
|
async restartToApplyUpdate(): Promise<void> {
|
||||||
await this.desktopUpdates.restartToApplyUpdate();
|
await this.desktopUpdates.restartToApplyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async setupDesktopDeepLinks(): Promise<void> {
|
||||||
|
const electronApi = this.getDeepLinkElectronApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
|
||||||
|
void this.handleDesktopDeepLink(url);
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
|
||||||
|
|
||||||
|
if (pendingDeepLink) {
|
||||||
|
await this.handleDesktopDeepLink(pendingDeepLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDesktopDeepLink(url: string): Promise<void> {
|
||||||
|
const invite = this.parseDesktopInviteUrl(url);
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.router.navigate(['/invite', invite.inviteId], {
|
||||||
|
queryParams: {
|
||||||
|
server: invite.sourceUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeepLinkElectronApi(): DeepLinkElectronApi | null {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
? (window as DeepLinkWindow).electronAPI ?? null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPublicRoute(url: string): boolean {
|
||||||
|
return url === '/login' ||
|
||||||
|
url === '/register' ||
|
||||||
|
url.startsWith('/invite/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
|
||||||
|
if (parsedUrl.protocol !== 'toju:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
|
||||||
|
.map((segment) => decodeURIComponent(segment));
|
||||||
|
|
||||||
|
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
|
||||||
|
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inviteId: pathSegments[1],
|
||||||
|
sourceUrl
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface Room {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
@@ -80,6 +81,9 @@ export interface Room {
|
|||||||
permissions?: RoomPermissions;
|
permissions?: RoomPermissions;
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
|
sourceId?: string;
|
||||||
|
sourceName?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
@@ -88,6 +92,7 @@ export interface RoomSettings {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
rules?: string[];
|
rules?: string[];
|
||||||
}
|
}
|
||||||
@@ -265,14 +270,14 @@ export interface ChatEvent {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
settings?: RoomSettings;
|
settings?: Partial<RoomSettings>;
|
||||||
permissions?: Partial<RoomPermissions>;
|
permissions?: Partial<RoomPermissions>;
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
isScreenSharing?: boolean;
|
isScreenSharing?: boolean;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconUpdatedAt?: number;
|
iconUpdatedAt?: number;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
room?: Room;
|
room?: Partial<Room>;
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
ban?: BanEntry;
|
ban?: BanEntry;
|
||||||
@@ -292,11 +297,13 @@ export interface ServerInfo {
|
|||||||
ownerPublicKey?: string;
|
ownerPublicKey?: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
|
hasPassword?: boolean;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JoinRequest {
|
export interface JoinRequest {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
effect
|
effect
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { take } from 'rxjs';
|
import { take } from 'rxjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { WebRTCService } from './webrtc.service';
|
import { WebRTCService } from './webrtc.service';
|
||||||
@@ -12,6 +13,7 @@ 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 { ROOM_URL_PATTERN } from '../constants';
|
||||||
import type {
|
import type {
|
||||||
ChatAttachmentAnnouncement,
|
ChatAttachmentAnnouncement,
|
||||||
ChatAttachmentMeta,
|
ChatAttachmentMeta,
|
||||||
@@ -145,9 +147,14 @@ export class AttachmentService {
|
|||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
private readonly ngrxStore = inject(Store);
|
private readonly ngrxStore = inject(Store);
|
||||||
private readonly database = inject(DatabaseService);
|
private readonly database = inject(DatabaseService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
/** Primary index: `messageId → Attachment[]`. */
|
/** Primary index: `messageId → Attachment[]`. */
|
||||||
private attachmentsByMessage = new Map<string, Attachment[]>();
|
private attachmentsByMessage = new Map<string, Attachment[]>();
|
||||||
|
/** Runtime cache of `messageId → roomId` for attachment gating. */
|
||||||
|
private messageRoomIds = new Map<string, string>();
|
||||||
|
/** Room currently being watched in the router, or `null` outside room routes. */
|
||||||
|
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
||||||
|
|
||||||
/** Incremented on every mutation so signal consumers re-render. */
|
/** Incremented on every mutation so signal consumers re-render. */
|
||||||
updated = signal<number>(0);
|
updated = signal<number>(0);
|
||||||
@@ -190,6 +197,24 @@ export class AttachmentService {
|
|||||||
this.initFromDatabase();
|
this.initFromDatabase();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.router.events.subscribe((event) => {
|
||||||
|
if (!(event instanceof NavigationEnd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
||||||
|
|
||||||
|
if (this.watchedRoomId) {
|
||||||
|
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.webrtc.onPeerConnected.subscribe(() => {
|
||||||
|
if (this.watchedRoomId) {
|
||||||
|
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElectronApi(): AttachmentElectronApi | undefined {
|
private getElectronApi(): AttachmentElectronApi | undefined {
|
||||||
@@ -201,6 +226,44 @@ export class AttachmentService {
|
|||||||
return this.attachmentsByMessage.get(messageId) ?? [];
|
return this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cache the room that owns a message so background downloads can be gated by the watched server. */
|
||||||
|
rememberMessageRoom(messageId: string, roomId: string): void {
|
||||||
|
if (!messageId || !roomId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.messageRoomIds.set(messageId, roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue best-effort auto-download checks for a message's eligible attachments. */
|
||||||
|
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
|
||||||
|
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-request eligible missing attachments for the currently watched room. */
|
||||||
|
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||||
|
if (!roomId || !this.isRoomWatched(roomId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.database.isReady()) {
|
||||||
|
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
this.rememberMessageRoom(message.id, message.roomId);
|
||||||
|
await this.requestAutoDownloadsForMessage(message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [messageId] of this.attachmentsByMessage) {
|
||||||
|
const attachmentRoomId = await this.resolveMessageRoomId(messageId);
|
||||||
|
|
||||||
|
if (attachmentRoomId === roomId) {
|
||||||
|
await this.requestAutoDownloadsForMessage(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove every attachment associated with a message. */
|
/** Remove every attachment associated with a message. */
|
||||||
async deleteForMessage(messageId: string): Promise<void> {
|
async deleteForMessage(messageId: string): Promise<void> {
|
||||||
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
@@ -219,6 +282,7 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.attachmentsByMessage.delete(messageId);
|
this.attachmentsByMessage.delete(messageId);
|
||||||
|
this.messageRoomIds.delete(messageId);
|
||||||
this.clearMessageScopedState(messageId);
|
this.clearMessageScopedState(messageId);
|
||||||
|
|
||||||
if (hadCachedAttachments) {
|
if (hadCachedAttachments) {
|
||||||
@@ -276,8 +340,15 @@ export class AttachmentService {
|
|||||||
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
||||||
*/
|
*/
|
||||||
registerSyncedAttachments(
|
registerSyncedAttachments(
|
||||||
attachmentMap: Record<string, AttachmentMeta[]>
|
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||||
|
messageRoomIds?: Record<string, string>
|
||||||
): void {
|
): void {
|
||||||
|
if (messageRoomIds) {
|
||||||
|
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
|
||||||
|
this.rememberMessageRoom(messageId, roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newAttachments: Attachment[] = [];
|
const newAttachments: Attachment[] = [];
|
||||||
|
|
||||||
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
||||||
@@ -306,6 +377,7 @@ export class AttachmentService {
|
|||||||
|
|
||||||
for (const attachment of newAttachments) {
|
for (const attachment of newAttachments) {
|
||||||
void this.persistAttachmentMeta(attachment);
|
void this.persistAttachmentMeta(attachment);
|
||||||
|
this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,8 +448,8 @@ export class AttachmentService {
|
|||||||
*
|
*
|
||||||
* 1. Each file is assigned a UUID.
|
* 1. Each file is assigned a UUID.
|
||||||
* 2. A `file-announce` event is broadcast to peers.
|
* 2. A `file-announce` event is broadcast to peers.
|
||||||
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
|
* 3. Peers watching the message's server can request any
|
||||||
* are immediately streamed as chunked base-64.
|
* auto-download-eligible media on demand.
|
||||||
*
|
*
|
||||||
* @param messageId - ID of the parent message.
|
* @param messageId - ID of the parent message.
|
||||||
* @param files - Array of user-selected `File` objects.
|
* @param files - Array of user-selected `File` objects.
|
||||||
@@ -437,10 +509,6 @@ export class AttachmentService {
|
|||||||
|
|
||||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||||
|
|
||||||
// Auto-stream small inline-preview media
|
|
||||||
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
|
||||||
await this.streamFileToPeers(messageId, fileId, file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingList = this.attachmentsByMessage.get(messageId) ?? [];
|
const existingList = this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
@@ -482,6 +550,7 @@ export class AttachmentService {
|
|||||||
this.attachmentsByMessage.set(messageId, list);
|
this.attachmentsByMessage.set(messageId, list);
|
||||||
this.touch();
|
this.touch();
|
||||||
void this.persistAttachmentMeta(attachment);
|
void this.persistAttachmentMeta(attachment);
|
||||||
|
this.queueAutoDownloadsForMessage(messageId, attachment.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -772,6 +841,38 @@ export class AttachmentService {
|
|||||||
return `${messageId}:${fileId}`;
|
return `${messageId}:${fileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
||||||
|
if (!messageId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const roomId = await this.resolveMessageRoomId(messageId);
|
||||||
|
|
||||||
|
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachmentId && attachment.id !== attachmentId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!this.shouldAutoRequestWhenWatched(attachment))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (attachment.available)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if ((attachment.receivedBytes ?? 0) > 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (this.pendingRequests.has(this.buildRequestKey(messageId, attachment.id)))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
this.requestFromAnyPeer(messageId, attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private clearMessageScopedState(messageId: string): void {
|
private clearMessageScopedState(messageId: string): void {
|
||||||
const scopedPrefix = `${messageId}:`;
|
const scopedPrefix = `${messageId}:`;
|
||||||
|
|
||||||
@@ -867,6 +968,12 @@ export class AttachmentService {
|
|||||||
attachment.mime.startsWith('audio/');
|
attachment.mime.startsWith('audio/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-download only the assets that already supported eager loading when watched. */
|
||||||
|
private shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
|
||||||
|
return attachment.isImage ||
|
||||||
|
(this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
/** Check whether a completed download should be cached on disk. */
|
/** Check whether a completed download should be cached on disk. */
|
||||||
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
|
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
|
||||||
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
||||||
@@ -1167,6 +1274,38 @@ export class AttachmentService {
|
|||||||
} catch { /* load is best-effort */ }
|
} catch { /* load is best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractWatchedRoomId(url: string): string | null {
|
||||||
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
|
|
||||||
|
return roomMatch ? roomMatch[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRoomWatched(roomId: string | null | undefined): boolean {
|
||||||
|
return !!roomId && roomId === this.watchedRoomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveMessageRoomId(messageId: string): Promise<string | null> {
|
||||||
|
const cachedRoomId = this.messageRoomIds.get(messageId);
|
||||||
|
|
||||||
|
if (cachedRoomId)
|
||||||
|
return cachedRoomId;
|
||||||
|
|
||||||
|
if (!this.database.isReady())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await this.database.getMessageById(messageId);
|
||||||
|
|
||||||
|
if (!message?.roomId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
this.rememberMessageRoom(messageId, message.roomId);
|
||||||
|
return message.roomId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One-time migration from localStorage to the database. */
|
/** One-time migration from localStorage to the database. */
|
||||||
private async migrateFromLocalStorage(): Promise<void> {
|
private async migrateFromLocalStorage(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ export class DebuggingService {
|
|||||||
|
|
||||||
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
||||||
.trim() || '(empty console call)';
|
.trim() || '(empty console call)';
|
||||||
|
|
||||||
// Use only string args for label/message extraction so that
|
// Use only string args for label/message extraction so that
|
||||||
// stringified object payloads don't pollute the parsed message.
|
// stringified object payloads don't pollute the parsed message.
|
||||||
// Object payloads are captured separately via extractConsolePayload.
|
// Object payloads are captured separately via extractConsolePayload.
|
||||||
@@ -226,7 +225,6 @@ export class DebuggingService {
|
|||||||
.filter((arg): arg is string => typeof arg === 'string')
|
.filter((arg): arg is string => typeof arg === 'string')
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.trim() || rawMessage;
|
.trim() || rawMessage;
|
||||||
|
|
||||||
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
|
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
|
||||||
const payload = this.extractConsolePayload(args);
|
const payload = this.extractConsolePayload(args);
|
||||||
const payloadText = payload === undefined
|
const payloadText = payload === undefined
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsModalService {
|
export class SettingsModalService {
|
||||||
readonly isOpen = signal(false);
|
readonly isOpen = signal(false);
|
||||||
readonly activePage = signal<SettingsPage>('network');
|
readonly activePage = signal<SettingsPage>('general');
|
||||||
readonly targetServerId = signal<string | null>(null);
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
|
||||||
open(page: SettingsPage = 'network', serverId?: string): void {
|
open(page: SettingsPage = 'general', serverId?: string): void {
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
this.targetServerId.set(serverId ?? null);
|
this.targetServerId.set(serverId ?? null);
|
||||||
this.isOpen.set(true);
|
this.isOpen.set(true);
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import {
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
Subject,
|
||||||
|
Subscription
|
||||||
|
} from 'rxjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { SignalingMessage, ChatEvent } from '../models/index';
|
import { SignalingMessage, ChatEvent } from '../models/index';
|
||||||
import { TimeSyncService } from './time-sync.service';
|
import { TimeSyncService } from './time-sync.service';
|
||||||
@@ -71,6 +76,7 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
serverTime?: number;
|
serverTime?: number;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
serverIds?: string[];
|
||||||
users?: SignalingUserSummary[];
|
users?: SignalingUserSummary[];
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
fromUserId?: string;
|
fromUserId?: string;
|
||||||
@@ -87,13 +93,18 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||||
|
|
||||||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||||
private lastJoinedServer: JoinedServerInfo | null = null;
|
private readonly lastJoinedServerBySignalUrl = new Map<string, JoinedServerInfo>();
|
||||||
private readonly memberServerIds = new Set<string>();
|
private readonly memberServerIdsBySignalUrl = new Map<string, Set<string>>();
|
||||||
|
private readonly serverSignalingUrlMap = new Map<string, string>();
|
||||||
|
private readonly peerSignalingUrlMap = new Map<string, string>();
|
||||||
|
private readonly signalingManagers = new Map<string, SignalingManager>();
|
||||||
|
private readonly signalingSubscriptions = new Map<string, Subscription[]>();
|
||||||
|
private readonly signalingConnectionStates = new Map<string, boolean>();
|
||||||
private activeServerId: string | null = null;
|
private activeServerId: string | null = null;
|
||||||
/** The server ID where voice is currently active, or `null` when not in voice. */
|
/** The server ID where voice is currently active, or `null` when not in voice. */
|
||||||
private voiceServerId: string | null = null;
|
private voiceServerId: string | null = null;
|
||||||
/** Maps each remote peer ID to the server they were discovered from. */
|
/** Maps each remote peer ID to the shared servers they currently belong to. */
|
||||||
private readonly peerServerMap = new Map<string, string>();
|
private readonly peerServerMap = new Map<string, Set<string>>();
|
||||||
private readonly serviceDestroyed$ = new Subject<void>();
|
private readonly serviceDestroyed$ = new Subject<void>();
|
||||||
private remoteScreenShareRequestsEnabled = false;
|
private remoteScreenShareRequestsEnabled = false;
|
||||||
private readonly desiredRemoteScreenSharePeers = new Set<string>();
|
private readonly desiredRemoteScreenSharePeers = new Set<string>();
|
||||||
@@ -167,20 +178,12 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return this.mediaManager.voiceConnected$.asObservable();
|
return this.mediaManager.voiceConnected$.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly signalingManager: SignalingManager;
|
|
||||||
private readonly peerManager: PeerConnectionManager;
|
private readonly peerManager: PeerConnectionManager;
|
||||||
private readonly mediaManager: MediaManager;
|
private readonly mediaManager: MediaManager;
|
||||||
private readonly screenShareManager: ScreenShareManager;
|
private readonly screenShareManager: ScreenShareManager;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Create managers with null callbacks first to break circular initialization
|
// Create managers with null callbacks first to break circular initialization
|
||||||
this.signalingManager = new SignalingManager(
|
|
||||||
this.logger,
|
|
||||||
() => this.lastIdentifyCredentials,
|
|
||||||
() => this.lastJoinedServer,
|
|
||||||
() => this.memberServerIds
|
|
||||||
);
|
|
||||||
|
|
||||||
this.peerManager = new PeerConnectionManager(this.logger, null!);
|
this.peerManager = new PeerConnectionManager(this.logger, null!);
|
||||||
|
|
||||||
this.mediaManager = new MediaManager(this.logger, null!);
|
this.mediaManager = new MediaManager(this.logger, null!);
|
||||||
@@ -189,7 +192,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
// Now wire up cross-references (all managers are instantiated)
|
// Now wire up cross-references (all managers are instantiated)
|
||||||
this.peerManager.setCallbacks({
|
this.peerManager.setCallbacks({
|
||||||
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
|
sendRawMessage: (msg: Record<string, unknown>) => this.sendRawMessage(msg),
|
||||||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||||||
isSignalingConnected: (): boolean => this._isSignalingConnected(),
|
isSignalingConnected: (): boolean => this._isSignalingConnected(),
|
||||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
||||||
@@ -230,23 +233,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private wireManagerEvents(): void {
|
private wireManagerEvents(): void {
|
||||||
// Signaling → connection status
|
|
||||||
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
|
|
||||||
this._isSignalingConnected.set(connected);
|
|
||||||
|
|
||||||
if (connected)
|
|
||||||
this._hasEverConnected.set(true);
|
|
||||||
|
|
||||||
this._hasConnectionError.set(!connected);
|
|
||||||
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Signaling → message routing
|
|
||||||
this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg));
|
|
||||||
|
|
||||||
// Signaling → heartbeat → broadcast states
|
|
||||||
this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates());
|
|
||||||
|
|
||||||
// Internal control-plane messages for on-demand screen-share delivery.
|
// Internal control-plane messages for on-demand screen-share delivery.
|
||||||
this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event));
|
this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event));
|
||||||
|
|
||||||
@@ -275,6 +261,8 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
||||||
this.activeRemoteScreenSharePeers.delete(peerId);
|
this.activeRemoteScreenSharePeers.delete(peerId);
|
||||||
|
this.peerServerMap.delete(peerId);
|
||||||
|
this.peerSignalingUrlMap.delete(peerId);
|
||||||
this.screenShareManager.clearScreenShareRequest(peerId);
|
this.screenShareManager.clearScreenShareRequest(peerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,37 +279,145 @@ export class WebRTCService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSignalingMessage(message: IncomingSignalingMessage): void {
|
private ensureSignalingManager(signalUrl: string): SignalingManager {
|
||||||
|
const existingManager = this.signalingManagers.get(signalUrl);
|
||||||
|
|
||||||
|
if (existingManager) {
|
||||||
|
return existingManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new SignalingManager(
|
||||||
|
this.logger,
|
||||||
|
() => this.lastIdentifyCredentials,
|
||||||
|
() => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null,
|
||||||
|
() => this.getMemberServerIdsForSignalUrl(signalUrl)
|
||||||
|
);
|
||||||
|
const subscriptions: Subscription[] = [
|
||||||
|
manager.connectionStatus$.subscribe(({ connected, errorMessage }) =>
|
||||||
|
this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage)
|
||||||
|
),
|
||||||
|
manager.messageReceived$.subscribe((message) => this.handleSignalingMessage(message, signalUrl)),
|
||||||
|
manager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates())
|
||||||
|
];
|
||||||
|
|
||||||
|
this.signalingManagers.set(signalUrl, manager);
|
||||||
|
this.signalingSubscriptions.set(signalUrl, subscriptions);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSignalingConnectionStatus(
|
||||||
|
signalUrl: string,
|
||||||
|
connected: boolean,
|
||||||
|
errorMessage?: string
|
||||||
|
): void {
|
||||||
|
this.signalingConnectionStates.set(signalUrl, connected);
|
||||||
|
|
||||||
|
if (connected)
|
||||||
|
this._hasEverConnected.set(true);
|
||||||
|
|
||||||
|
const anyConnected = this.isAnySignalingConnected();
|
||||||
|
|
||||||
|
this._isSignalingConnected.set(anyConnected);
|
||||||
|
this._hasConnectionError.set(!anyConnected);
|
||||||
|
this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAnySignalingConnected(): boolean {
|
||||||
|
for (const manager of this.signalingManagers.values()) {
|
||||||
|
if (manager.isSocketOpen()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConnectedSignalingManagers(): { signalUrl: string; manager: SignalingManager }[] {
|
||||||
|
const connectedManagers: { signalUrl: string; manager: SignalingManager }[] = [];
|
||||||
|
|
||||||
|
for (const [signalUrl, manager] of this.signalingManagers.entries()) {
|
||||||
|
if (!manager.isSocketOpen()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedManagers.push({ signalUrl,
|
||||||
|
manager });
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectedManagers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateMemberServerSet(signalUrl: string): Set<string> {
|
||||||
|
const existingSet = this.memberServerIdsBySignalUrl.get(signalUrl);
|
||||||
|
|
||||||
|
if (existingSet) {
|
||||||
|
return existingSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdSet = new Set<string>();
|
||||||
|
|
||||||
|
this.memberServerIdsBySignalUrl.set(signalUrl, createdSet);
|
||||||
|
return createdSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet<string> {
|
||||||
|
return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isJoinedServer(serverId: string): boolean {
|
||||||
|
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||||
|
if (memberServerIds.has(serverId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getJoinedServerCount(): number {
|
||||||
|
let joinedServerCount = 0;
|
||||||
|
|
||||||
|
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||||
|
joinedServerCount += memberServerIds.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinedServerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
this.signalingMessage$.next(message);
|
this.signalingMessage$.next(message);
|
||||||
this.logger.info('Signaling message', { type: message.type });
|
this.logger.info('Signaling message', {
|
||||||
|
signalUrl,
|
||||||
|
type: message.type
|
||||||
|
});
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case SIGNALING_TYPE_CONNECTED:
|
case SIGNALING_TYPE_CONNECTED:
|
||||||
this.handleConnectedSignalingMessage(message);
|
this.handleConnectedSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_SERVER_USERS:
|
case SIGNALING_TYPE_SERVER_USERS:
|
||||||
this.handleServerUsersSignalingMessage(message);
|
this.handleServerUsersSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_JOINED:
|
case SIGNALING_TYPE_USER_JOINED:
|
||||||
this.handleUserJoinedSignalingMessage(message);
|
this.handleUserJoinedSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_LEFT:
|
case SIGNALING_TYPE_USER_LEFT:
|
||||||
this.handleUserLeftSignalingMessage(message);
|
this.handleUserLeftSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_OFFER:
|
case SIGNALING_TYPE_OFFER:
|
||||||
this.handleOfferSignalingMessage(message);
|
this.handleOfferSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_ANSWER:
|
case SIGNALING_TYPE_ANSWER:
|
||||||
this.handleAnswerSignalingMessage(message);
|
this.handleAnswerSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||||
this.handleIceCandidateSignalingMessage(message);
|
this.handleIceCandidateSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -329,26 +425,44 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
this.logger.info('Server connected', { oderId: message.oderId });
|
this.logger.info('Server connected', {
|
||||||
|
oderId: message.oderId,
|
||||||
|
signalUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.serverId) {
|
||||||
|
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof message.serverTime === 'number') {
|
if (typeof message.serverTime === 'number') {
|
||||||
this.timeSync.setFromServerTime(message.serverTime);
|
this.timeSync.setFromServerTime(message.serverTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
const users = Array.isArray(message.users) ? message.users : [];
|
const users = Array.isArray(message.users) ? message.users : [];
|
||||||
|
|
||||||
this.logger.info('Server users', {
|
this.logger.info('Server users', {
|
||||||
count: users.length,
|
count: users.length,
|
||||||
|
signalUrl,
|
||||||
serverId: message.serverId
|
serverId: message.serverId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (message.serverId) {
|
||||||
|
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (!user.oderId)
|
if (!user.oderId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
this.peerSignalingUrlMap.set(user.oderId, signalUrl);
|
||||||
|
|
||||||
|
if (message.serverId) {
|
||||||
|
this.trackPeerInServer(user.oderId, message.serverId);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||||
const healthy = this.isPeerHealthy(existing);
|
const healthy = this.isPeerHealthy(existing);
|
||||||
|
|
||||||
@@ -367,66 +481,91 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
this.peerManager.createPeerConnection(user.oderId, true);
|
this.peerManager.createPeerConnection(user.oderId, true);
|
||||||
this.peerManager.createAndSendOffer(user.oderId);
|
this.peerManager.createAndSendOffer(user.oderId);
|
||||||
|
|
||||||
if (message.serverId) {
|
|
||||||
this.peerServerMap.set(user.oderId, message.serverId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
this.logger.info('User joined', {
|
this.logger.info('User joined', {
|
||||||
displayName: message.displayName,
|
displayName: message.displayName,
|
||||||
oderId: message.oderId
|
oderId: message.oderId,
|
||||||
|
signalUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (message.serverId) {
|
||||||
|
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
|
if (message.oderId) {
|
||||||
|
this.peerSignalingUrlMap.set(message.oderId, signalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.oderId && message.serverId) {
|
||||||
|
this.trackPeerInServer(message.oderId, message.serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
this.logger.info('User left', {
|
this.logger.info('User left', {
|
||||||
displayName: message.displayName,
|
displayName: message.displayName,
|
||||||
oderId: message.oderId,
|
oderId: message.oderId,
|
||||||
|
signalUrl,
|
||||||
serverId: message.serverId
|
serverId: message.serverId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message.oderId) {
|
if (message.oderId) {
|
||||||
|
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
||||||
|
? this.replacePeerSharedServers(message.oderId, message.serverIds)
|
||||||
|
: (message.serverId
|
||||||
|
? this.untrackPeerFromServer(message.oderId, message.serverId)
|
||||||
|
: false);
|
||||||
|
|
||||||
|
if (!hasRemainingSharedServers) {
|
||||||
this.peerManager.removePeer(message.oderId);
|
this.peerManager.removePeer(message.oderId);
|
||||||
this.peerServerMap.delete(message.oderId);
|
this.peerServerMap.delete(message.oderId);
|
||||||
|
this.peerSignalingUrlMap.delete(message.oderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const sdp = message.payload?.sdp;
|
const sdp = message.payload?.sdp;
|
||||||
|
|
||||||
if (!fromUserId || !sdp)
|
if (!fromUserId || !sdp)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
||||||
|
|
||||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||||
|
|
||||||
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
||||||
this.peerServerMap.set(fromUserId, offerEffectiveServer);
|
this.trackPeerInServer(fromUserId, offerEffectiveServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.peerManager.handleOffer(fromUserId, sdp);
|
this.peerManager.handleOffer(fromUserId, sdp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const sdp = message.payload?.sdp;
|
const sdp = message.payload?.sdp;
|
||||||
|
|
||||||
if (!fromUserId || !sdp)
|
if (!fromUserId || !sdp)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
||||||
|
|
||||||
this.peerManager.handleAnswer(fromUserId, sdp);
|
this.peerManager.handleAnswer(fromUserId, sdp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const candidate = message.payload?.candidate;
|
const candidate = message.payload?.candidate;
|
||||||
|
|
||||||
if (!fromUserId || !candidate)
|
if (!fromUserId || !candidate)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
||||||
|
|
||||||
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,8 +580,8 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private closePeersNotInServer(serverId: string): void {
|
private closePeersNotInServer(serverId: string): void {
|
||||||
const peersToClose: string[] = [];
|
const peersToClose: string[] = [];
|
||||||
|
|
||||||
this.peerServerMap.forEach((peerServerId, peerId) => {
|
this.peerServerMap.forEach((peerServerIds, peerId) => {
|
||||||
if (peerServerId !== serverId) {
|
if (!peerServerIds.has(serverId)) {
|
||||||
peersToClose.push(peerId);
|
peersToClose.push(peerId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -453,6 +592,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
this.peerManager.removePeer(peerId);
|
this.peerManager.removePeer(peerId);
|
||||||
this.peerServerMap.delete(peerId);
|
this.peerServerMap.delete(peerId);
|
||||||
|
this.peerSignalingUrlMap.delete(peerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +616,57 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @returns An observable that emits `true` once connected.
|
* @returns An observable that emits `true` once connected.
|
||||||
*/
|
*/
|
||||||
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
||||||
return this.signalingManager.connect(serverUrl);
|
const manager = this.ensureSignalingManager(serverUrl);
|
||||||
|
|
||||||
|
if (manager.isSocketOpen()) {
|
||||||
|
return of(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager.connect(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when the signaling socket for a given URL is currently open. */
|
||||||
|
isSignalingConnectedTo(serverUrl: string): boolean {
|
||||||
|
return this.signalingManagers.get(serverUrl)?.isSocketOpen() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackPeerInServer(peerId: string, serverId: string): void {
|
||||||
|
if (!peerId || !serverId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const trackedServers = this.peerServerMap.get(peerId) ?? new Set<string>();
|
||||||
|
|
||||||
|
trackedServers.add(serverId);
|
||||||
|
this.peerServerMap.set(peerId, trackedServers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private replacePeerSharedServers(peerId: string, serverIds: string[]): boolean {
|
||||||
|
const sharedServerIds = serverIds.filter((serverId) => this.isJoinedServer(serverId));
|
||||||
|
|
||||||
|
if (sharedServerIds.length === 0) {
|
||||||
|
this.peerServerMap.delete(peerId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peerServerMap.set(peerId, new Set(sharedServerIds));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private untrackPeerFromServer(peerId: string, serverId: string): boolean {
|
||||||
|
const trackedServers = this.peerServerMap.get(peerId);
|
||||||
|
|
||||||
|
if (!trackedServers)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
trackedServers.delete(serverId);
|
||||||
|
|
||||||
|
if (trackedServers.size === 0) {
|
||||||
|
this.peerServerMap.delete(peerId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peerServerMap.set(peerId, trackedServers);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -486,7 +676,17 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @returns `true` if connected within the timeout.
|
* @returns `true` if connected within the timeout.
|
||||||
*/
|
*/
|
||||||
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||||||
return this.signalingManager.ensureConnected(timeoutMs);
|
if (this.isAnySignalingConnected()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const manager of this.signalingManagers.values()) {
|
||||||
|
if (await manager.ensureConnected(timeoutMs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -495,7 +695,32 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param message - The signaling message payload (excluding `from` / `timestamp`).
|
* @param message - The signaling message payload (excluding `from` / `timestamp`).
|
||||||
*/
|
*/
|
||||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
||||||
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
|
const targetPeerId = message.to;
|
||||||
|
|
||||||
|
if (targetPeerId) {
|
||||||
|
const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId);
|
||||||
|
|
||||||
|
if (targetSignalUrl) {
|
||||||
|
const targetManager = this.ensureSignalingManager(targetSignalUrl);
|
||||||
|
|
||||||
|
targetManager.sendSignalingMessage(message, this._localPeerId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedManagers = this.getConnectedSignalingManagers();
|
||||||
|
|
||||||
|
if (connectedManagers.length === 0) {
|
||||||
|
this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
||||||
|
type: message.type
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { manager } of connectedManagers) {
|
||||||
|
manager.sendSignalingMessage(message, this._localPeerId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -504,7 +729,50 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param message - Arbitrary JSON message.
|
* @param message - Arbitrary JSON message.
|
||||||
*/
|
*/
|
||||||
sendRawMessage(message: Record<string, unknown>): void {
|
sendRawMessage(message: Record<string, unknown>): void {
|
||||||
this.signalingManager.sendRawMessage(message);
|
const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null;
|
||||||
|
|
||||||
|
if (targetPeerId) {
|
||||||
|
const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId);
|
||||||
|
|
||||||
|
if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const serverSignalUrl = this.serverSignalingUrlMap.get(serverId);
|
||||||
|
|
||||||
|
if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedManagers = this.getConnectedSignalingManagers();
|
||||||
|
|
||||||
|
if (connectedManagers.length === 0) {
|
||||||
|
this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
||||||
|
type: typeof message['type'] === 'string' ? message['type'] : 'unknown'
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { manager } of connectedManagers) {
|
||||||
|
manager.sendRawMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRawMessageToSignalUrl(signalUrl: string, message: Record<string, unknown>): boolean {
|
||||||
|
const manager = this.signalingManagers.get(signalUrl);
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.sendRawMessage(message);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -521,6 +789,19 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return this.activeServerId;
|
return this.activeServerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The last signaling URL used by the client, if any. */
|
||||||
|
getCurrentSignalingUrl(): string | null {
|
||||||
|
if (this.activeServerId) {
|
||||||
|
const activeServerSignalUrl = this.serverSignalingUrlMap.get(this.activeServerId);
|
||||||
|
|
||||||
|
if (activeServerSignalUrl) {
|
||||||
|
return activeServerSignalUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an identify message to the signaling server.
|
* Send an identify message to the signaling server.
|
||||||
*
|
*
|
||||||
@@ -529,13 +810,22 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param oderId - The user's unique order/peer ID.
|
* @param oderId - The user's unique order/peer ID.
|
||||||
* @param displayName - The user's display name.
|
* @param displayName - The user's display name.
|
||||||
*/
|
*/
|
||||||
identify(oderId: string, displayName: string): void {
|
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
||||||
this.lastIdentifyCredentials = { oderId,
|
this.lastIdentifyCredentials = { oderId,
|
||||||
displayName };
|
displayName };
|
||||||
|
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
const identifyMessage = {
|
||||||
|
type: SIGNALING_TYPE_IDENTIFY,
|
||||||
oderId,
|
oderId,
|
||||||
displayName });
|
displayName
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signalUrl) {
|
||||||
|
this.sendRawMessageToSignalUrl(signalUrl, identifyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendRawMessage(identifyMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -544,13 +834,27 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param roomId - The server / room ID to join.
|
* @param roomId - The server / room ID to join.
|
||||||
* @param userId - The local user ID.
|
* @param userId - The local user ID.
|
||||||
*/
|
*/
|
||||||
joinRoom(roomId: string, userId: string): void {
|
joinRoom(roomId: string, userId: string, signalUrl?: string): void {
|
||||||
this.lastJoinedServer = { serverId: roomId,
|
const resolvedSignalUrl = signalUrl
|
||||||
userId };
|
?? this.serverSignalingUrlMap.get(roomId)
|
||||||
|
?? this.getCurrentSignalingUrl();
|
||||||
|
|
||||||
this.memberServerIds.add(roomId);
|
if (!resolvedSignalUrl) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
this.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId });
|
||||||
serverId: roomId });
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverSignalingUrlMap.set(roomId, resolvedSignalUrl);
|
||||||
|
this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, {
|
||||||
|
serverId: roomId,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
this.getOrCreateMemberServerSet(resolvedSignalUrl).add(roomId);
|
||||||
|
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||||
|
type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
|
serverId: roomId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -560,26 +864,46 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param serverId - The target server ID.
|
* @param serverId - The target server ID.
|
||||||
* @param userId - The local user ID.
|
* @param userId - The local user ID.
|
||||||
*/
|
*/
|
||||||
switchServer(serverId: string, userId: string): void {
|
switchServer(serverId: string, userId: string, signalUrl?: string): void {
|
||||||
this.lastJoinedServer = { serverId,
|
const resolvedSignalUrl = signalUrl
|
||||||
userId };
|
?? this.serverSignalingUrlMap.get(serverId)
|
||||||
|
?? this.getCurrentSignalingUrl();
|
||||||
|
|
||||||
if (this.memberServerIds.has(serverId)) {
|
if (!resolvedSignalUrl) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
this.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId });
|
||||||
serverId });
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverSignalingUrlMap.set(serverId, resolvedSignalUrl);
|
||||||
|
this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, {
|
||||||
|
serverId,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberServerIds = this.getOrCreateMemberServerSet(resolvedSignalUrl);
|
||||||
|
|
||||||
|
if (memberServerIds.has(serverId)) {
|
||||||
|
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||||
|
type: SIGNALING_TYPE_VIEW_SERVER,
|
||||||
|
serverId
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.info('Viewed server (already joined)', {
|
this.logger.info('Viewed server (already joined)', {
|
||||||
serverId,
|
serverId,
|
||||||
|
signalUrl: resolvedSignalUrl,
|
||||||
userId,
|
userId,
|
||||||
voiceConnected: this._isVoiceConnected()
|
voiceConnected: this._isVoiceConnected()
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.memberServerIds.add(serverId);
|
memberServerIds.add(serverId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||||
serverId });
|
type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
|
serverId
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.info('Joined new server via switch', {
|
this.logger.info('Joined new server via switch', {
|
||||||
serverId,
|
serverId,
|
||||||
|
signalUrl: resolvedSignalUrl,
|
||||||
userId,
|
userId,
|
||||||
voiceConnected: this._isVoiceConnected()
|
voiceConnected: this._isVoiceConnected()
|
||||||
});
|
});
|
||||||
@@ -596,25 +920,47 @@ export class WebRTCService implements OnDestroy {
|
|||||||
*/
|
*/
|
||||||
leaveRoom(serverId?: string): void {
|
leaveRoom(serverId?: string): void {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
this.memberServerIds.delete(serverId);
|
const resolvedSignalUrl = this.serverSignalingUrlMap.get(serverId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
|
||||||
serverId });
|
if (resolvedSignalUrl) {
|
||||||
|
this.getOrCreateMemberServerSet(resolvedSignalUrl).delete(serverId);
|
||||||
|
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||||
|
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
|
serverId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.sendRawMessage({
|
||||||
|
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
|
serverId
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||||
|
memberServerIds.delete(serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverSignalingUrlMap.delete(serverId);
|
||||||
|
|
||||||
this.logger.info('Left server', { serverId });
|
this.logger.info('Left server', { serverId });
|
||||||
|
|
||||||
if (this.memberServerIds.size === 0) {
|
if (this.getJoinedServerCount() === 0) {
|
||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.memberServerIds.forEach((sid) => {
|
for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
for (const sid of memberServerIds) {
|
||||||
serverId: sid });
|
this.sendRawMessageToSignalUrl(signalUrl, {
|
||||||
|
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
|
serverId: sid
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.memberServerIds.clear();
|
this.memberServerIdsBySignalUrl.clear();
|
||||||
|
this.serverSignalingUrlMap.clear();
|
||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,12 +970,18 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param serverId - The server to check.
|
* @param serverId - The server to check.
|
||||||
*/
|
*/
|
||||||
hasJoinedServer(serverId: string): boolean {
|
hasJoinedServer(serverId: string): boolean {
|
||||||
return this.memberServerIds.has(serverId);
|
return this.isJoinedServer(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns a read-only set of all currently-joined server IDs. */
|
/** Returns a read-only set of all currently-joined server IDs. */
|
||||||
getJoinedServerIds(): ReadonlySet<string> {
|
getJoinedServerIds(): ReadonlySet<string> {
|
||||||
return this.memberServerIds;
|
const joinedServerIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||||
|
memberServerIds.forEach((serverId) => joinedServerIds.add(serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinedServerIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -884,11 +1236,15 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
/** Disconnect from the signaling server and clean up all state. */
|
/** Disconnect from the signaling server and clean up all state. */
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
this.leaveRoom();
|
||||||
this.voiceServerId = null;
|
this.voiceServerId = null;
|
||||||
this.peerServerMap.clear();
|
this.peerServerMap.clear();
|
||||||
this.leaveRoom();
|
this.peerSignalingUrlMap.clear();
|
||||||
|
this.lastJoinedServerBySignalUrl.clear();
|
||||||
|
this.memberServerIdsBySignalUrl.clear();
|
||||||
|
this.serverSignalingUrlMap.clear();
|
||||||
this.mediaManager.stopVoiceHeartbeat();
|
this.mediaManager.stopVoiceHeartbeat();
|
||||||
this.signalingManager.close();
|
this.destroyAllSignalingManagers();
|
||||||
this._isSignalingConnected.set(false);
|
this._isSignalingConnected.set(false);
|
||||||
this._hasEverConnected.set(false);
|
this._hasEverConnected.set(false);
|
||||||
this._hasConnectionError.set(false);
|
this._hasConnectionError.set(false);
|
||||||
@@ -904,6 +1260,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private fullCleanup(): void {
|
private fullCleanup(): void {
|
||||||
this.voiceServerId = null;
|
this.voiceServerId = null;
|
||||||
this.peerServerMap.clear();
|
this.peerServerMap.clear();
|
||||||
|
this.peerSignalingUrlMap.clear();
|
||||||
this.remoteScreenShareRequestsEnabled = false;
|
this.remoteScreenShareRequestsEnabled = false;
|
||||||
this.desiredRemoteScreenSharePeers.clear();
|
this.desiredRemoteScreenSharePeers.clear();
|
||||||
this.activeRemoteScreenSharePeers.clear();
|
this.activeRemoteScreenSharePeers.clear();
|
||||||
@@ -982,10 +1339,25 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private destroyAllSignalingManagers(): void {
|
||||||
|
for (const subscriptions of this.signalingSubscriptions.values()) {
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const manager of this.signalingManagers.values()) {
|
||||||
|
manager.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.signalingSubscriptions.clear();
|
||||||
|
this.signalingManagers.clear();
|
||||||
|
this.signalingConnectionStates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
this.serviceDestroyed$.complete();
|
this.serviceDestroyed$.complete();
|
||||||
this.signalingManager.destroy();
|
|
||||||
this.peerManager.destroy();
|
this.peerManager.destroy();
|
||||||
this.mediaManager.destroy();
|
this.mediaManager.destroy();
|
||||||
this.screenShareManager.destroy();
|
this.screenShareManager.destroy();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideLogIn } from '@ng-icons/lucide';
|
import { lucideLogIn } from '@ng-icons/lucide';
|
||||||
@@ -42,6 +42,7 @@ export class LoginComponent {
|
|||||||
|
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
@@ -72,6 +73,14 @@ export class LoginComponent {
|
|||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
if (returnUrl?.startsWith('/')) {
|
||||||
|
this.router.navigateByUrl(returnUrl);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -82,6 +91,10 @@ export class LoginComponent {
|
|||||||
|
|
||||||
/** Navigate to the registration page. */
|
/** Navigate to the registration page. */
|
||||||
goRegister() {
|
goRegister() {
|
||||||
this.router.navigate(['/register']);
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
this.router.navigate(['/register'], {
|
||||||
|
queryParams: returnUrl ? { returnUrl } : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||||
@@ -43,6 +43,7 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
@@ -74,6 +75,14 @@ export class RegisterComponent {
|
|||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
if (returnUrl?.startsWith('/')) {
|
||||||
|
this.router.navigateByUrl(returnUrl);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -84,6 +93,10 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
/** Navigate to the login page. */
|
/** Navigate to the login page. */
|
||||||
goLogin() {
|
goLogin() {
|
||||||
this.router.navigate(['/login']);
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: returnUrl ? { returnUrl } : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
DestroyRef
|
DestroyRef,
|
||||||
|
effect
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import {
|
import {
|
||||||
merge,
|
merge,
|
||||||
interval,
|
interval,
|
||||||
@@ -23,6 +26,7 @@ interface TypingSignalingMessage {
|
|||||||
type: string;
|
type: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
oderId: string;
|
oderId: string;
|
||||||
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -36,6 +40,9 @@ interface TypingSignalingMessage {
|
|||||||
})
|
})
|
||||||
export class TypingIndicatorComponent {
|
export class TypingIndicatorComponent {
|
||||||
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
private lastRoomId: string | null = null;
|
||||||
|
|
||||||
typingDisplay = signal<string[]>([]);
|
typingDisplay = signal<string[]>([]);
|
||||||
typingOthersCount = signal<number>(0);
|
typingOthersCount = signal<number>(0);
|
||||||
@@ -47,8 +54,10 @@ export class TypingIndicatorComponent {
|
|||||||
filter((msg): msg is TypingSignalingMessage =>
|
filter((msg): msg is TypingSignalingMessage =>
|
||||||
msg?.type === 'user_typing' &&
|
msg?.type === 'user_typing' &&
|
||||||
typeof msg.displayName === 'string' &&
|
typeof msg.displayName === 'string' &&
|
||||||
typeof msg.oderId === 'string'
|
typeof msg.oderId === 'string' &&
|
||||||
|
typeof msg.serverId === 'string'
|
||||||
),
|
),
|
||||||
|
filter((msg) => msg.serverId === this.currentRoom()?.id),
|
||||||
tap((msg) => {
|
tap((msg) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -77,6 +86,17 @@ export class TypingIndicatorComponent {
|
|||||||
merge(typing$, purge$)
|
merge(typing$, purge$)
|
||||||
.pipe(takeUntilDestroyed(destroyRef))
|
.pipe(takeUntilDestroyed(destroyRef))
|
||||||
.subscribe(() => this.recomputeDisplay());
|
.subscribe(() => this.recomputeDisplay());
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const roomId = this.currentRoom()?.id ?? null;
|
||||||
|
|
||||||
|
if (roomId === this.lastRoomId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.lastRoomId = roomId;
|
||||||
|
this.typingMap.clear();
|
||||||
|
this.recomputeDisplay();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private recomputeDisplay(): void {
|
private recomputeDisplay(): void {
|
||||||
|
|||||||
85
src/app/features/invite/invite.component.html
Normal file
85
src/app/features/invite/invite.component.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<div class="min-h-full bg-background px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
|
||||||
|
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
|
||||||
|
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground"
|
||||||
|
>
|
||||||
|
Invite link
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
||||||
|
@if (invite()) {
|
||||||
|
Join {{ invite()!.server.name }}
|
||||||
|
} @else {
|
||||||
|
Toju server invite
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||||||
|
@switch (status()) {
|
||||||
|
@case ('redirecting') {
|
||||||
|
Sign in to continue with this invite.
|
||||||
|
}
|
||||||
|
@case ('joining') {
|
||||||
|
We are connecting you to the invited server.
|
||||||
|
}
|
||||||
|
@case ('error') {
|
||||||
|
This invite could not be completed automatically.
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
Loading invite details and preparing the correct signal server.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 px-6 py-8 sm:px-10 lg:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Status</h2>
|
||||||
|
<p class="mt-3 text-lg font-medium text-foreground">{{ message() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (invite()) {
|
||||||
|
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Server</h2>
|
||||||
|
<p class="mt-3 text-xl font-semibold text-foreground">{{ invite()!.server.name }}</p>
|
||||||
|
@if (invite()!.server.description) {
|
||||||
|
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ invite()!.server.description }}</p>
|
||||||
|
}
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||||
|
@if (invite()!.server.isPrivate) {
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Private</span>
|
||||||
|
}
|
||||||
|
@if (invite()!.server.hasPassword) {
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
|
||||||
|
}
|
||||||
|
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary"> Expires {{ invite()!.expiresAt | date: 'medium' }} </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="space-y-4">
|
||||||
|
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">What happens next</h2>
|
||||||
|
<ul class="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
<li>• The linked signal server is added to your configured server list if needed.</li>
|
||||||
|
<li>• Invite links bypass private and password restrictions.</li>
|
||||||
|
<li>• Banned users still cannot join through invites.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (status() === 'error') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="goToSearch()"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Back to server search
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
192
src/app/features/invite/invite.component.ts
Normal file
192
src/app/features/invite/invite.component.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
|
import { UsersActions } from '../../store/users/users.actions';
|
||||||
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
|
import { ServerDirectoryService, ServerInviteInfo } from '../../core/services/server-directory.service';
|
||||||
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||||
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { User } from '../../core/models/index';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-invite',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './invite.component.html'
|
||||||
|
})
|
||||||
|
export class InviteComponent implements OnInit {
|
||||||
|
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||||
|
readonly invite = signal<ServerInviteInfo | null>(null);
|
||||||
|
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
||||||
|
readonly message = signal('Loading invite…');
|
||||||
|
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||||
|
private readonly databaseService = inject(DatabaseService);
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
const inviteContext = this.resolveInviteContext();
|
||||||
|
|
||||||
|
if (!inviteContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
await this.redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.joinInvite(inviteContext, currentUserId);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.applyInviteError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goToSearch(): void {
|
||||||
|
this.router.navigate(['/search']).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEndpointName(sourceUrl: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(sourceUrl);
|
||||||
|
|
||||||
|
return url.hostname;
|
||||||
|
} catch {
|
||||||
|
return 'Signal Server';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyInviteError(error: unknown): void {
|
||||||
|
const inviteError = error as {
|
||||||
|
error?: { error?: string; errorCode?: string };
|
||||||
|
};
|
||||||
|
const errorCode = inviteError?.error?.errorCode;
|
||||||
|
const fallbackMessage = inviteError?.error?.error || 'Unable to accept this invite.';
|
||||||
|
|
||||||
|
this.status.set('error');
|
||||||
|
|
||||||
|
if (errorCode === 'BANNED') {
|
||||||
|
this.message.set('You are banned from this server and cannot accept this invite.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCode === 'INVITE_EXPIRED') {
|
||||||
|
this.message.set('This invite has expired. Ask for a fresh invite link.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.message.set(fallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrateCurrentUser(): Promise<User | null> {
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
return currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedUser = await this.databaseService.getCurrentUser();
|
||||||
|
|
||||||
|
if (storedUser) {
|
||||||
|
this.store.dispatch(UsersActions.setCurrentUser({ user: storedUser }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return storedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async joinInvite(
|
||||||
|
context: { endpoint: { id: string; name: string }; inviteId: string; sourceUrl: string },
|
||||||
|
currentUserId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const invite = await firstValueFrom(this.serverDirectory.getInvite(context.inviteId, {
|
||||||
|
sourceId: context.endpoint.id,
|
||||||
|
sourceUrl: context.sourceUrl
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.invite.set(invite);
|
||||||
|
this.status.set('joining');
|
||||||
|
this.message.set(`Joining ${invite.server.name}…`);
|
||||||
|
|
||||||
|
const currentUser = await this.hydrateCurrentUser();
|
||||||
|
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||||
|
roomId: invite.server.id,
|
||||||
|
userId: currentUserId,
|
||||||
|
userPublicKey: currentUser?.oderId || currentUserId,
|
||||||
|
displayName: currentUser?.displayName || 'Anonymous',
|
||||||
|
inviteId: context.inviteId
|
||||||
|
}, {
|
||||||
|
sourceId: context.endpoint.id,
|
||||||
|
sourceUrl: context.sourceUrl
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.joinRoom({
|
||||||
|
roomId: joinResponse.server.id,
|
||||||
|
serverInfo: {
|
||||||
|
...joinResponse.server,
|
||||||
|
sourceId: context.endpoint.id,
|
||||||
|
sourceName: context.endpoint.name,
|
||||||
|
sourceUrl: context.sourceUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async redirectToLogin(): Promise<void> {
|
||||||
|
this.status.set('redirecting');
|
||||||
|
this.message.set('Redirecting to login…');
|
||||||
|
|
||||||
|
await this.router.navigate(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
returnUrl: this.router.url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveInviteContext(): {
|
||||||
|
endpoint: { id: string; name: string };
|
||||||
|
inviteId: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
} | null {
|
||||||
|
const inviteId = this.route.snapshot.paramMap.get('inviteId')?.trim() || '';
|
||||||
|
const sourceUrl = this.route.snapshot.queryParamMap.get('server')?.trim() || '';
|
||||||
|
|
||||||
|
if (!inviteId || !sourceUrl) {
|
||||||
|
this.status.set('error');
|
||||||
|
this.message.set('This invite link is missing required server information.');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||||
|
name: this.buildEndpointName(sourceUrl),
|
||||||
|
url: sourceUrl
|
||||||
|
}, {
|
||||||
|
setActive: !localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: {
|
||||||
|
id: endpoint.id,
|
||||||
|
name: endpoint.name
|
||||||
|
},
|
||||||
|
inviteId,
|
||||||
|
sourceUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,17 @@
|
|||||||
name="lucideLock"
|
name="lucideLock"
|
||||||
class="w-4 h-4 text-muted-foreground"
|
class="w-4 h-4 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||||
|
>Private</span
|
||||||
|
>
|
||||||
|
} @else if (server.hasPassword) {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideLock"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||||
|
>Password</span
|
||||||
|
>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideGlobe"
|
name="lucideGlobe"
|
||||||
@@ -153,6 +164,9 @@
|
|||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
|
||||||
|
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -160,9 +174,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (error()) {
|
@if (joinErrorMessage() || error()) {
|
||||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||||
<p class="text-sm text-destructive">{{ error() }}</p>
|
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -181,6 +195,41 @@
|
|||||||
</app-confirm-dialog>
|
</app-confirm-dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (showPasswordDialog() && passwordPromptServer()) {
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Password required"
|
||||||
|
confirmLabel="Join server"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
[widthClass]="'w-[420px] max-w-[92vw]'"
|
||||||
|
(confirmed)="confirmPasswordJoin()"
|
||||||
|
(cancelled)="closePasswordDialog()"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="join-server-password"
|
||||||
|
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Server password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="join-server-password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="joinPassword"
|
||||||
|
placeholder="Enter password"
|
||||||
|
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (joinPasswordError()) {
|
||||||
|
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-confirm-dialog>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Create Server Dialog -->
|
<!-- Create Server Dialog -->
|
||||||
@if (showCreateDialog()) {
|
@if (showCreateDialog()) {
|
||||||
<div
|
<div
|
||||||
@@ -249,6 +298,24 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="create-server-signal-endpoint"
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>Signal Server Endpoint</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="create-server-signal-endpoint"
|
||||||
|
[(ngModel)]="newServerSourceId"
|
||||||
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
@for (endpoint of activeEndpoints(); track endpoint.id) {
|
||||||
|
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this chat server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -263,22 +330,21 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (newServerPrivate()) {
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="create-server-password"
|
for="create-server-password"
|
||||||
class="block text-sm font-medium text-foreground mb-1"
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
>Password</label
|
>Password (optional)</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
[(ngModel)]="newServerPassword"
|
[(ngModel)]="newServerPassword"
|
||||||
placeholder="Enter password"
|
placeholder="Leave blank to allow joining without a password"
|
||||||
id="create-server-password"
|
id="create-server-password"
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-6">
|
<div class="flex gap-3 mt-6">
|
||||||
@@ -291,7 +357,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="createServer()"
|
(click)="createServer()"
|
||||||
[disabled]="!newServerName()"
|
[disabled]="!newServerName() || !newServerSourceId"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import {
|
import {
|
||||||
debounceTime,
|
debounceTime,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
|
firstValueFrom,
|
||||||
Subject
|
Subject
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
} from '../../core/models/index';
|
} from '../../core/models/index';
|
||||||
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { ConfirmDialogComponent } from '../../shared';
|
import { ConfirmDialogComponent } from '../../shared';
|
||||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
@@ -73,6 +75,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
|
|
||||||
@@ -82,9 +85,15 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
error = this.store.selectSignal(selectRoomsError);
|
error = this.store.selectSignal(selectRoomsError);
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
activeEndpoints = this.serverDirectory.activeServers;
|
||||||
bannedServerLookup = signal<Record<string, boolean>>({});
|
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||||
bannedServerName = signal('');
|
bannedServerName = signal('');
|
||||||
showBannedDialog = signal(false);
|
showBannedDialog = signal(false);
|
||||||
|
showPasswordDialog = signal(false);
|
||||||
|
passwordPromptServer = signal<ServerInfo | null>(null);
|
||||||
|
joinPassword = signal('');
|
||||||
|
joinPasswordError = signal<string | null>(null);
|
||||||
|
joinErrorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
// Create dialog state
|
// Create dialog state
|
||||||
showCreateDialog = signal(false);
|
showCreateDialog = signal(false);
|
||||||
@@ -93,6 +102,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
newServerTopic = signal('');
|
newServerTopic = signal('');
|
||||||
newServerPrivate = signal(false);
|
newServerPrivate = signal(false);
|
||||||
newServerPassword = signal('');
|
newServerPassword = signal('');
|
||||||
|
newServerSourceId = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -135,20 +145,12 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.dispatch(
|
await this.attemptJoinServer(server);
|
||||||
RoomsActions.joinRoom({
|
|
||||||
roomId: server.id,
|
|
||||||
serverInfo: {
|
|
||||||
name: server.name,
|
|
||||||
description: server.description,
|
|
||||||
hostName: server.sourceName || server.hostName
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the create-server dialog. */
|
/** Open the create-server dialog. */
|
||||||
openCreateDialog(): void {
|
openCreateDialog(): void {
|
||||||
|
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||||
this.showCreateDialog.set(true);
|
this.showCreateDialog.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +178,8 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
description: this.newServerDescription() || undefined,
|
description: this.newServerDescription() || undefined,
|
||||||
topic: this.newServerTopic() || undefined,
|
topic: this.newServerTopic() || undefined,
|
||||||
isPrivate: this.newServerPrivate(),
|
isPrivate: this.newServerPrivate(),
|
||||||
password: this.newServerPrivate() ? this.newServerPassword() : undefined
|
password: this.newServerPassword().trim() || undefined,
|
||||||
|
sourceId: this.newServerSourceId || undefined
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -198,6 +201,22 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.bannedServerName.set('');
|
this.bannedServerName.set('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closePasswordDialog(): void {
|
||||||
|
this.showPasswordDialog.set(false);
|
||||||
|
this.passwordPromptServer.set(null);
|
||||||
|
this.joinPassword.set('');
|
||||||
|
this.joinPasswordError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPasswordJoin(): Promise<void> {
|
||||||
|
const server = this.passwordPromptServer();
|
||||||
|
|
||||||
|
if (!server)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await this.attemptJoinServer(server, this.joinPassword());
|
||||||
|
}
|
||||||
|
|
||||||
isServerMarkedBanned(server: ServerInfo): boolean {
|
isServerMarkedBanned(server: ServerInfo): boolean {
|
||||||
return !!this.bannedServerLookup()[server.id];
|
return !!this.bannedServerLookup()[server.id];
|
||||||
}
|
}
|
||||||
@@ -223,12 +242,72 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
hostName: room.hostId || 'Unknown',
|
hostName: room.hostId || 'Unknown',
|
||||||
userCount: room.userCount ?? 0,
|
userCount: room.userCount ?? 0,
|
||||||
maxUsers: room.maxUsers ?? 50,
|
maxUsers: room.maxUsers ?? 50,
|
||||||
isPrivate: !!room.password,
|
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||||
|
isPrivate: room.isPrivate,
|
||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
ownerId: room.hostId
|
ownerId: room.hostId,
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceName: room.sourceName,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
|
||||||
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.joinErrorMessage.set(null);
|
||||||
|
this.joinPasswordError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||||
|
roomId: server.id,
|
||||||
|
userId: currentUserId,
|
||||||
|
userPublicKey: currentUser?.oderId || currentUserId,
|
||||||
|
displayName: currentUser?.displayName || 'Anonymous',
|
||||||
|
password: password?.trim() || undefined
|
||||||
|
}, {
|
||||||
|
sourceId: server.sourceId,
|
||||||
|
sourceUrl: server.sourceUrl
|
||||||
|
}));
|
||||||
|
const resolvedServer = response.server ?? server;
|
||||||
|
|
||||||
|
this.closePasswordDialog();
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.joinRoom({
|
||||||
|
roomId: resolvedServer.id,
|
||||||
|
serverInfo: resolvedServer
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const serverError = error as {
|
||||||
|
error?: { error?: string; errorCode?: string };
|
||||||
|
};
|
||||||
|
const errorCode = serverError?.error?.errorCode;
|
||||||
|
const message = serverError?.error?.error || 'Failed to join server';
|
||||||
|
|
||||||
|
if (errorCode === 'PASSWORD_REQUIRED') {
|
||||||
|
this.passwordPromptServer.set(server);
|
||||||
|
this.showPasswordDialog.set(true);
|
||||||
|
this.joinPasswordError.set(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCode === 'BANNED') {
|
||||||
|
this.bannedServerName.set(server.name);
|
||||||
|
this.showBannedDialog.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.joinErrorMessage.set(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||||
const requestVersion = ++this.banLookupRequestVersion;
|
const requestVersion = ++this.banLookupRequestVersion;
|
||||||
|
|
||||||
@@ -271,5 +350,6 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.newServerTopic.set('');
|
this.newServerTopic.set('');
|
||||||
this.newServerPrivate.set(false);
|
this.newServerPrivate.set(false);
|
||||||
this.newServerPassword.set('');
|
this.newServerPassword.set('');
|
||||||
|
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,41 @@
|
|||||||
</app-confirm-dialog>
|
</app-confirm-dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (showPasswordDialog() && passwordPromptRoom()) {
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Password required"
|
||||||
|
confirmLabel="Join server"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
[widthClass]="'w-[420px] max-w-[92vw]'"
|
||||||
|
(confirmed)="confirmPasswordJoin()"
|
||||||
|
(cancelled)="closePasswordDialog()"
|
||||||
|
>
|
||||||
|
<div class="space-y-3 text-left">
|
||||||
|
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="rail-join-password"
|
||||||
|
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Server password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rail-join-password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="joinPassword"
|
||||||
|
placeholder="Enter password"
|
||||||
|
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (joinPasswordError()) {
|
||||||
|
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-confirm-dialog>
|
||||||
|
}
|
||||||
|
|
||||||
@if (showLeaveConfirm() && contextRoom()) {
|
@if (showLeaveConfirm() && contextRoom()) {
|
||||||
<app-leave-server-dialog
|
<app-leave-server-dialog
|
||||||
[room]="contextRoom()!"
|
[room]="contextRoom()!"
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Router } from '@angular/router';
|
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 { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { Room, User } from '../../core/models/index';
|
import { Room, User } from '../../core/models/index';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
@@ -19,6 +21,7 @@ import { VoiceSessionService } from '../../core/services/voice-session.service';
|
|||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
import {
|
import {
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
@@ -46,6 +50,7 @@ export class ServersRailComponent {
|
|||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
@@ -59,6 +64,10 @@ export class ServersRailComponent {
|
|||||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||||
bannedServerName = signal('');
|
bannedServerName = signal('');
|
||||||
showBannedDialog = signal(false);
|
showBannedDialog = signal(false);
|
||||||
|
showPasswordDialog = signal(false);
|
||||||
|
passwordPromptRoom = signal<Room | null>(null);
|
||||||
|
joinPassword = signal('');
|
||||||
|
joinPasswordError = signal<string | null>(null);
|
||||||
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -105,27 +114,20 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
});
|
||||||
|
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
|
||||||
|
|
||||||
if (voiceServerId && voiceServerId !== room.id) {
|
this.prepareVoiceContext(room);
|
||||||
this.voiceSession.setViewingVoiceServer(false);
|
|
||||||
} else if (voiceServerId === room.id) {
|
|
||||||
this.voiceSession.setViewingVoiceServer(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.webrtc.hasJoinedServer(room.id)) {
|
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||||
|
skipBanCheck: true }));
|
||||||
} else {
|
} else {
|
||||||
this.store.dispatch(
|
await this.attemptJoinRoom(room);
|
||||||
RoomsActions.joinRoom({
|
|
||||||
roomId: room.id,
|
|
||||||
serverInfo: {
|
|
||||||
name: room.name,
|
|
||||||
description: room.description,
|
|
||||||
hostName: room.hostId || 'Unknown'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +136,22 @@ export class ServersRailComponent {
|
|||||||
this.bannedServerName.set('');
|
this.bannedServerName.set('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closePasswordDialog(): void {
|
||||||
|
this.showPasswordDialog.set(false);
|
||||||
|
this.passwordPromptRoom.set(null);
|
||||||
|
this.joinPassword.set('');
|
||||||
|
this.joinPasswordError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPasswordJoin(): Promise<void> {
|
||||||
|
const room = this.passwordPromptRoom();
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await this.attemptJoinRoom(room, this.joinPassword());
|
||||||
|
}
|
||||||
|
|
||||||
isRoomMarkedBanned(room: Room): boolean {
|
isRoomMarkedBanned(room: Room): boolean {
|
||||||
return !!this.bannedRoomLookup()[room.id];
|
return !!this.bannedRoomLookup()[room.id];
|
||||||
}
|
}
|
||||||
@@ -226,4 +244,106 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private prepareVoiceContext(room: Room): void {
|
||||||
|
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||||
|
|
||||||
|
if (voiceServerId && voiceServerId !== room.id) {
|
||||||
|
this.voiceSession.setViewingVoiceServer(false);
|
||||||
|
} else if (voiceServerId === room.id) {
|
||||||
|
this.voiceSession.setViewingVoiceServer(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attemptJoinRoom(room: Room, password?: string): Promise<void> {
|
||||||
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
if (!currentUserId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.joinPasswordError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||||
|
roomId: room.id,
|
||||||
|
userId: currentUserId,
|
||||||
|
userPublicKey: currentUser?.oderId || currentUserId,
|
||||||
|
displayName: currentUser?.displayName || 'Anonymous',
|
||||||
|
password: password?.trim() || undefined
|
||||||
|
}, {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.closePasswordDialog();
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.joinRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
serverInfo: {
|
||||||
|
...this.toServerInfo(room),
|
||||||
|
...response.server
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const serverError = error as {
|
||||||
|
error?: { error?: string; errorCode?: string };
|
||||||
|
};
|
||||||
|
const errorCode = serverError?.error?.errorCode;
|
||||||
|
const message = serverError?.error?.error || 'Failed to join server';
|
||||||
|
|
||||||
|
if (errorCode === 'PASSWORD_REQUIRED') {
|
||||||
|
this.passwordPromptRoom.set(room);
|
||||||
|
this.showPasswordDialog.set(true);
|
||||||
|
this.joinPasswordError.set(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCode === 'BANNED') {
|
||||||
|
this.bannedServerName.set(room.name);
|
||||||
|
this.showBannedDialog.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldFallbackToOfflineView(error)) {
|
||||||
|
this.closePasswordDialog();
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
||||||
|
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||||
|
skipBanCheck: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldFallbackToOfflineView(error: unknown): boolean {
|
||||||
|
const serverError = error as {
|
||||||
|
error?: { errorCode?: string };
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
const errorCode = serverError?.error?.errorCode;
|
||||||
|
const status = serverError?.status;
|
||||||
|
|
||||||
|
return errorCode === 'SERVER_NOT_FOUND'
|
||||||
|
|| status === 0
|
||||||
|
|| status === 404
|
||||||
|
|| (typeof status === 'number' && status >= 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toServerInfo(room: Room) {
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
name: room.name,
|
||||||
|
description: room.description,
|
||||||
|
hostName: room.hostId || 'Unknown',
|
||||||
|
userCount: room.userCount ?? 0,
|
||||||
|
maxUsers: room.maxUsers ?? 50,
|
||||||
|
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||||
|
isPrivate: room.isPrivate,
|
||||||
|
createdAt: room.createdAt,
|
||||||
|
ownerId: room.hostId,
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceName: room.sourceName,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<div class="space-y-6 max-w-xl">
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<ng-icon
|
||||||
|
name="lucidePower"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground">Application</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
||||||
|
[class.opacity-60]="!isElectron"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
|
||||||
|
|
||||||
|
@if (isElectron) {
|
||||||
|
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
|
||||||
|
} @else {
|
||||||
|
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="relative inline-flex items-center"
|
||||||
|
[class.cursor-pointer]="isElectron && !savingAutoStart()"
|
||||||
|
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="autoStart()"
|
||||||
|
[disabled]="!isElectron || savingAutoStart()"
|
||||||
|
(change)="onAutoStartChange($event)"
|
||||||
|
id="general-auto-start-toggle"
|
||||||
|
aria-label="Toggle launch on startup"
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { lucidePower } from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../../../core/services/platform.service';
|
||||||
|
|
||||||
|
interface DesktopSettingsSnapshot {
|
||||||
|
autoStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneralSettingsElectronApi {
|
||||||
|
getDesktopSettings?: () => Promise<DesktopSettingsSnapshot>;
|
||||||
|
setDesktopSettings?: (patch: { autoStart?: boolean }) => Promise<DesktopSettingsSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneralSettingsWindow = Window & {
|
||||||
|
electronAPI?: GeneralSettingsElectronApi;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-general-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, NgIcon],
|
||||||
|
viewProviders: [
|
||||||
|
provideIcons({
|
||||||
|
lucidePower
|
||||||
|
})
|
||||||
|
],
|
||||||
|
templateUrl: './general-settings.component.html'
|
||||||
|
})
|
||||||
|
export class GeneralSettingsComponent {
|
||||||
|
private platform = inject(PlatformService);
|
||||||
|
|
||||||
|
readonly isElectron = this.platform.isElectron;
|
||||||
|
autoStart = signal(false);
|
||||||
|
savingAutoStart = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (this.isElectron) {
|
||||||
|
void this.loadDesktopSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAutoStartChange(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const enabled = !!input.checked;
|
||||||
|
const api = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!this.isElectron || !api?.setDesktopSettings) {
|
||||||
|
input.checked = this.autoStart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.savingAutoStart.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await api.setDesktopSettings({ autoStart: enabled });
|
||||||
|
|
||||||
|
this.autoStart.set(snapshot.autoStart);
|
||||||
|
} catch {
|
||||||
|
input.checked = this.autoStart();
|
||||||
|
} finally {
|
||||||
|
this.savingAutoStart.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDesktopSettings(): Promise<void> {
|
||||||
|
const api = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!api?.getDesktopSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await api.getDesktopSettings();
|
||||||
|
|
||||||
|
this.autoStart.set(snapshot.autoStart);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getElectronApi(): GeneralSettingsElectronApi | null {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
? (window as GeneralSettingsWindow).electronAPI ?? null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,18 @@
|
|||||||
/>
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if (hasMissingDefaultServers()) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="restoreDefaultServers()"
|
||||||
|
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Restore Defaults
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
(click)="testAllServers()"
|
(click)="testAllServers()"
|
||||||
[disabled]="isTesting()"
|
[disabled]="isTesting()"
|
||||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||||
@@ -22,8 +33,11 @@
|
|||||||
Test All
|
Test All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground mb-3">Server directories to search for rooms. The active server is used for creating new rooms.</p>
|
<p class="text-xs text-muted-foreground mb-3">
|
||||||
|
Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
<div class="space-y-2 mb-3">
|
<div class="space-y-2 mb-3">
|
||||||
@@ -41,6 +55,7 @@
|
|||||||
[class.bg-red-500]="server.status === 'offline'"
|
[class.bg-red-500]="server.status === 'offline'"
|
||||||
[class.bg-yellow-500]="server.status === 'checking'"
|
[class.bg-yellow-500]="server.status === 'checking'"
|
||||||
[class.bg-muted]="server.status === 'unknown'"
|
[class.bg-muted]="server.status === 'unknown'"
|
||||||
|
[class.bg-orange-500]="server.status === 'incompatible'"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -53,13 +68,17 @@
|
|||||||
@if (server.latency !== undefined && server.status === 'online') {
|
@if (server.latency !== undefined && server.status === 'online') {
|
||||||
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
|
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
|
||||||
}
|
}
|
||||||
|
@if (server.status === 'incompatible') {
|
||||||
|
<p class="text-[10px] text-destructive">Update the client in order to connect to other users</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 flex-shrink-0">
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
@if (!server.isActive) {
|
@if (!server.isActive && server.status !== 'incompatible') {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="setActiveServer(server.id)"
|
(click)="setActiveServer(server.id)"
|
||||||
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
||||||
title="Set as active"
|
title="Activate"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideCheck"
|
name="lucideCheck"
|
||||||
@@ -67,8 +86,22 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!server.isDefault) {
|
@if (server.isActive && hasMultipleActiveServers()) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="deactivateServer(server.id)"
|
||||||
|
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
||||||
|
title="Deactivate"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (hasMultipleServers()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
(click)="removeServer(server.id)"
|
(click)="removeServer(server.id)"
|
||||||
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||||
title="Remove"
|
title="Remove"
|
||||||
@@ -103,6 +136,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="addServer()"
|
(click)="addServer()"
|
||||||
[disabled]="!newServerName || !newServerUrl"
|
[disabled]="!newServerName || !newServerUrl"
|
||||||
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal,
|
||||||
|
computed
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -13,7 +14,8 @@ import {
|
|||||||
lucideRefreshCw,
|
lucideRefreshCw,
|
||||||
lucidePlus,
|
lucidePlus,
|
||||||
lucideTrash2,
|
lucideTrash2,
|
||||||
lucideCheck
|
lucideCheck,
|
||||||
|
lucideX
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
||||||
@@ -34,7 +36,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
|||||||
lucideRefreshCw,
|
lucideRefreshCw,
|
||||||
lucidePlus,
|
lucidePlus,
|
||||||
lucideTrash2,
|
lucideTrash2,
|
||||||
lucideCheck
|
lucideCheck,
|
||||||
|
lucideX
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './network-settings.component.html'
|
templateUrl: './network-settings.component.html'
|
||||||
@@ -43,6 +46,10 @@ export class NetworkSettingsComponent {
|
|||||||
private serverDirectory = inject(ServerDirectoryService);
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
|
|
||||||
servers = this.serverDirectory.servers;
|
servers = this.serverDirectory.servers;
|
||||||
|
activeServers = this.serverDirectory.activeServers;
|
||||||
|
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||||
|
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||||
|
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||||
isTesting = signal(false);
|
isTesting = signal(false);
|
||||||
addError = signal<string | null>(null);
|
addError = signal<string | null>(null);
|
||||||
newServerName = '';
|
newServerName = '';
|
||||||
@@ -91,6 +98,14 @@ export class NetworkSettingsComponent {
|
|||||||
this.serverDirectory.setActiveServer(id);
|
this.serverDirectory.setActiveServer(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deactivateServer(id: string): void {
|
||||||
|
this.serverDirectory.deactivateServer(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDefaultServers(): void {
|
||||||
|
this.serverDirectory.restoreDefaultServers();
|
||||||
|
}
|
||||||
|
|
||||||
async testAllServers(): Promise<void> {
|
async testAllServers(): Promise<void> {
|
||||||
this.isTesting.set(true);
|
this.isTesting.set(true);
|
||||||
await this.serverDirectory.testAllServers();
|
await this.serverDirectory.testAllServers();
|
||||||
|
|||||||
@@ -95,6 +95,84 @@
|
|||||||
[class.cursor-not-allowed]="!isAdmin()"
|
[class.cursor-not-allowed]="!isAdmin()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (isAdmin()) {
|
||||||
|
<div class="rounded-lg border border-border bg-secondary/40 p-4 space-y-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">Server Password</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
@if (hasPassword() && passwordAction() !== 'remove') {
|
||||||
|
Joined members stay whitelisted until they are kicked or banned.
|
||||||
|
} @else {
|
||||||
|
Add an optional password so new members need it to join.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hasPassword() && passwordAction() !== 'remove') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="markPasswordForRemoval()"
|
||||||
|
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Remove Password
|
||||||
|
</button>
|
||||||
|
} @else if (hasPassword() && passwordAction() === 'remove') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="keepCurrentPassword()"
|
||||||
|
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Keep Password
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
@if (hasPassword() && passwordAction() !== 'remove') {
|
||||||
|
Password protection is currently enabled.
|
||||||
|
} @else if (hasPassword() && passwordAction() === 'remove') {
|
||||||
|
Password protection will be removed when you save.
|
||||||
|
} @else {
|
||||||
|
Password protection is currently disabled.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="room-password"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
|
{{ hasPassword() ? 'Set New Password' : 'Set Password' }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="room-password"
|
||||||
|
[ngModel]="roomPassword"
|
||||||
|
(ngModelChange)="onPasswordInput($event)"
|
||||||
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
[placeholder]="hasPassword() ? 'Leave blank to keep the current password' : 'Optional password required for new joins'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (passwordAction() === 'update') {
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">The new password will replace the current one when you save.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (passwordError()) {
|
||||||
|
<p class="mt-2 text-xs text-destructive">{{ passwordError() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">Server Password</p>
|
||||||
|
<p class="text-xs text-muted-foreground">Invite links bypass the password, but bans still apply.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground">{{ hasPassword() ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export class ServerSettingsComponent {
|
|||||||
roomName = '';
|
roomName = '';
|
||||||
roomDescription = '';
|
roomDescription = '';
|
||||||
isPrivate = signal(false);
|
isPrivate = signal(false);
|
||||||
|
hasPassword = signal(false);
|
||||||
|
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
||||||
|
passwordError = signal<string | null>(null);
|
||||||
|
roomPassword = '';
|
||||||
maxUsers = 0;
|
maxUsers = 0;
|
||||||
showDeleteConfirm = signal(false);
|
showDeleteConfirm = signal(false);
|
||||||
|
|
||||||
@@ -72,6 +76,10 @@ export class ServerSettingsComponent {
|
|||||||
this.roomName = room.name;
|
this.roomName = room.name;
|
||||||
this.roomDescription = room.description || '';
|
this.roomDescription = room.description || '';
|
||||||
this.isPrivate.set(room.isPrivate);
|
this.isPrivate.set(room.isPrivate);
|
||||||
|
this.hasPassword.set(typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password);
|
||||||
|
this.passwordAction.set('keep');
|
||||||
|
this.passwordError.set(null);
|
||||||
|
this.roomPassword = '';
|
||||||
this.maxUsers = room.maxUsers || 0;
|
this.maxUsers = room.maxUsers || 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,21 +94,67 @@ export class ServerSettingsComponent {
|
|||||||
if (!room)
|
if (!room)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.store.dispatch(
|
const normalizedPassword = this.roomPassword.trim();
|
||||||
RoomsActions.updateRoomSettings({
|
const settings: {
|
||||||
roomId: room.id,
|
description: string;
|
||||||
settings: {
|
hasPassword?: boolean;
|
||||||
|
isPrivate: boolean;
|
||||||
|
maxUsers: number;
|
||||||
|
name: string;
|
||||||
|
password?: string;
|
||||||
|
} = {
|
||||||
name: this.roomName,
|
name: this.roomName,
|
||||||
description: this.roomDescription,
|
description: this.roomDescription,
|
||||||
isPrivate: this.isPrivate(),
|
isPrivate: this.isPrivate(),
|
||||||
maxUsers: this.maxUsers
|
maxUsers: this.maxUsers
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.passwordAction() === 'remove') {
|
||||||
|
settings.password = '';
|
||||||
|
settings.hasPassword = false;
|
||||||
|
} else if (normalizedPassword) {
|
||||||
|
settings.password = normalizedPassword;
|
||||||
|
settings.hasPassword = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.updateRoomSettings({
|
||||||
|
roomId: room.id,
|
||||||
|
settings
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPassword.set(settings.hasPassword ?? this.hasPassword());
|
||||||
|
this.passwordAction.set('keep');
|
||||||
|
this.passwordError.set(null);
|
||||||
|
this.roomPassword = '';
|
||||||
this.showSaveSuccess('server');
|
this.showSaveSuccess('server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markPasswordForRemoval(): void {
|
||||||
|
this.passwordAction.set('remove');
|
||||||
|
this.passwordError.set(null);
|
||||||
|
this.roomPassword = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
keepCurrentPassword(): void {
|
||||||
|
this.passwordAction.set('keep');
|
||||||
|
this.passwordError.set(null);
|
||||||
|
this.roomPassword = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onPasswordInput(value: string): void {
|
||||||
|
this.roomPassword = value;
|
||||||
|
this.passwordError.set(null);
|
||||||
|
|
||||||
|
if (value.trim().length > 0) {
|
||||||
|
this.passwordAction.set('update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.passwordAction.set('keep');
|
||||||
|
}
|
||||||
|
|
||||||
confirmDeleteRoom(): void {
|
confirmDeleteRoom(): void {
|
||||||
this.showDeleteConfirm.set(true);
|
this.showDeleteConfirm.set(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,9 @@
|
|||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h3 class="text-lg font-semibold text-foreground">
|
<h3 class="text-lg font-semibold text-foreground">
|
||||||
@switch (activePage()) {
|
@switch (activePage()) {
|
||||||
|
@case ('general') {
|
||||||
|
General
|
||||||
|
}
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
Network
|
Network
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,9 @@
|
|||||||
<!-- Scrollable Content Area -->
|
<!-- Scrollable Content Area -->
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
@switch (activePage()) {
|
@switch (activePage()) {
|
||||||
|
@case ('general') {
|
||||||
|
<app-general-settings />
|
||||||
|
}
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
<app-network-settings />
|
<app-network-settings />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { 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';
|
||||||
|
|
||||||
|
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
||||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||||
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
||||||
import { ServerSettingsComponent } from './server-settings/server-settings.component';
|
import { ServerSettingsComponent } from './server-settings/server-settings.component';
|
||||||
@@ -48,6 +49,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
|
GeneralSettingsComponent,
|
||||||
NetworkSettingsComponent,
|
NetworkSettingsComponent,
|
||||||
VoiceSettingsComponent,
|
VoiceSettingsComponent,
|
||||||
UpdatesSettingsComponent,
|
UpdatesSettingsComponent,
|
||||||
@@ -89,6 +91,9 @@ export class SettingsModalComponent {
|
|||||||
activePage = this.modal.activePage;
|
activePage = this.modal.activePage;
|
||||||
|
|
||||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
|
{ id: 'general',
|
||||||
|
label: 'General',
|
||||||
|
icon: 'lucideSettings' },
|
||||||
{ id: 'network',
|
{ id: 'network',
|
||||||
label: 'Network',
|
label: 'Network',
|
||||||
icon: 'lucideGlobe' },
|
icon: 'lucideGlobe' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div class="p-6 max-w-2xl mx-auto">
|
<div class="p-6 max-w-2xl mx-auto">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="goBack()"
|
(click)="goBack()"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||||
title="Go back"
|
title="Go back"
|
||||||
@@ -27,7 +28,18 @@
|
|||||||
/>
|
/>
|
||||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if (hasMissingDefaultServers()) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="restoreDefaultServers()"
|
||||||
|
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Restore Defaults
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
(click)="testAllServers()"
|
(click)="testAllServers()"
|
||||||
[disabled]="isTesting()"
|
[disabled]="isTesting()"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||||
@@ -40,10 +52,10 @@
|
|||||||
Test All
|
Test All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
|
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
|
||||||
rooms.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
@@ -63,6 +75,7 @@
|
|||||||
[class.bg-red-500]="server.status === 'offline'"
|
[class.bg-red-500]="server.status === 'offline'"
|
||||||
[class.bg-yellow-500]="server.status === 'checking'"
|
[class.bg-yellow-500]="server.status === 'checking'"
|
||||||
[class.bg-muted]="server.status === 'unknown'"
|
[class.bg-muted]="server.status === 'unknown'"
|
||||||
|
[class.bg-orange-500]="server.status === 'incompatible'"
|
||||||
[title]="server.status"
|
[title]="server.status"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@@ -78,15 +91,19 @@
|
|||||||
@if (server.latency !== undefined && server.status === 'online') {
|
@if (server.latency !== undefined && server.status === 'online') {
|
||||||
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
||||||
}
|
}
|
||||||
|
@if (server.status === 'incompatible') {
|
||||||
|
<p class="text-xs text-destructive">Update the client in order to connect to other users</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
@if (!server.isActive) {
|
@if (!server.isActive && server.status !== 'incompatible') {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="setActiveServer(server.id)"
|
(click)="setActiveServer(server.id)"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||||
title="Set as active"
|
title="Activate"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideCheck"
|
name="lucideCheck"
|
||||||
@@ -94,8 +111,22 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!server.isDefault) {
|
@if (server.isActive && hasMultipleActiveServers()) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="deactivateServer(server.id)"
|
||||||
|
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||||
|
title="Deactivate"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-4 h-4 text-muted-foreground hover:text-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (hasMultipleServers()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
(click)="removeServer(server.id)"
|
(click)="removeServer(server.id)"
|
||||||
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||||
title="Remove server"
|
title="Remove server"
|
||||||
@@ -130,6 +161,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="addServer()"
|
(click)="addServer()"
|
||||||
[disabled]="!newServerName || !newServerUrl"
|
[disabled]="!newServerName || !newServerUrl"
|
||||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||||
@@ -228,6 +260,7 @@
|
|||||||
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
|
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="previewNotificationSound()"
|
(click)="previewNotificationSound()"
|
||||||
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||||
title="Preview sound"
|
title="Preview sound"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
OnInit
|
OnInit,
|
||||||
|
computed
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -61,6 +62,10 @@ export class SettingsComponent implements OnInit {
|
|||||||
audioService = inject(NotificationAudioService);
|
audioService = inject(NotificationAudioService);
|
||||||
|
|
||||||
servers = this.serverDirectory.servers;
|
servers = this.serverDirectory.servers;
|
||||||
|
activeServers = this.serverDirectory.activeServers;
|
||||||
|
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||||
|
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||||
|
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||||
isTesting = signal(false);
|
isTesting = signal(false);
|
||||||
addError = signal<string | null>(null);
|
addError = signal<string | null>(null);
|
||||||
|
|
||||||
@@ -122,6 +127,14 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.serverDirectory.setActiveServer(id);
|
this.serverDirectory.setActiveServer(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deactivateServer(id: string): void {
|
||||||
|
this.serverDirectory.deactivateServer(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDefaultServers(): void {
|
||||||
|
this.serverDirectory.restoreDefaultServers();
|
||||||
|
}
|
||||||
|
|
||||||
/** Test connectivity to all configured servers. */
|
/** Test connectivity to all configured servers. */
|
||||||
async testAllServers(): Promise<void> {
|
async testAllServers(): Promise<void> {
|
||||||
this.isTesting.set(true);
|
this.isTesting.set(true);
|
||||||
|
|||||||
@@ -13,6 +13,22 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||||
|
|
||||||
|
@if (showRoomCompatibilityNotice()) {
|
||||||
|
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||||
|
{{ signalServerCompatibilityError() }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showRoomReconnectNotice()) {
|
||||||
|
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideRefreshCw"
|
||||||
|
class="h-3.5 w-3.5 animate-spin"
|
||||||
|
/>
|
||||||
|
Reconnecting to signal server…
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
@if (roomDescription()) {
|
@if (roomDescription()) {
|
||||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||||
{{ roomDescription() }}
|
{{ roomDescription() }}
|
||||||
@@ -21,9 +37,11 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||||
@if (isReconnecting()) {
|
<span
|
||||||
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||||
}
|
[class.hidden]="!isReconnecting()"
|
||||||
|
>Reconnecting…</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -31,16 +49,15 @@
|
|||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
style="-webkit-app-region: no-drag"
|
style="-webkit-app-region: no-drag"
|
||||||
>
|
>
|
||||||
@if (!isAuthed()) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||||
|
[class.hidden]="isAuthed()"
|
||||||
(click)="goLogin()"
|
(click)="goLogin()"
|
||||||
title="Login"
|
title="Login"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -55,8 +72,20 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- Anchored dropdown under the menu button -->
|
<!-- Anchored dropdown under the menu button -->
|
||||||
@if (showMenu()) {
|
@if (showMenu()) {
|
||||||
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card shadow-lg">
|
||||||
@if (inRoom()) {
|
@if (inRoom()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="createInviteLink()"
|
||||||
|
[disabled]="creatingInvite()"
|
||||||
|
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
@if (creatingInvite()) {
|
||||||
|
Creating Invite Link…
|
||||||
|
} @else {
|
||||||
|
Create Invite Link
|
||||||
|
}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="leaveServer()"
|
(click)="leaveServer()"
|
||||||
@@ -65,6 +94,12 @@
|
|||||||
Leave Server
|
Leave Server
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
<div
|
||||||
|
class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||||
|
[class.hidden]="!inviteStatus()"
|
||||||
|
>
|
||||||
|
{{ inviteStatus() }}
|
||||||
|
</div>
|
||||||
<div class="border-t border-border"></div>
|
<div class="border-t border-border"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
@@ -14,10 +15,15 @@ import {
|
|||||||
lucideX,
|
lucideX,
|
||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
lucideHash,
|
lucideHash,
|
||||||
lucideMenu
|
lucideMenu,
|
||||||
|
lucideRefreshCw
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import {
|
||||||
|
selectCurrentRoom,
|
||||||
|
selectIsSignalServerReconnecting,
|
||||||
|
selectSignalServerCompatibilityError
|
||||||
|
} from '../../store/rooms/rooms.selectors';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
@@ -25,6 +31,7 @@ import { WebRTCService } from '../../core/services/webrtc.service';
|
|||||||
import { PlatformService } from '../../core/services/platform.service';
|
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';
|
||||||
|
import { Room } from '../../core/models/index';
|
||||||
|
|
||||||
interface WindowControlsAPI {
|
interface WindowControlsAPI {
|
||||||
minimizeWindow?: () => void;
|
minimizeWindow?: () => void;
|
||||||
@@ -50,7 +57,8 @@ type ElectronWindow = Window & {
|
|||||||
lucideX,
|
lucideX,
|
||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
lucideHash,
|
lucideHash,
|
||||||
lucideMenu })
|
lucideMenu,
|
||||||
|
lucideRefreshCw })
|
||||||
],
|
],
|
||||||
templateUrl: './title-bar.component.html'
|
templateUrl: './title-bar.component.html'
|
||||||
})
|
})
|
||||||
@@ -78,12 +86,28 @@ export class TitleBarComponent {
|
|||||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||||
isAuthed = computed(() => !!this.currentUser());
|
isAuthed = computed(() => !!this.currentUser());
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||||
|
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||||
inRoom = computed(() => !!this.currentRoom());
|
inRoom = computed(() => !!this.currentRoom());
|
||||||
roomName = computed(() => this.currentRoom()?.name || '');
|
roomName = computed(() => this.currentRoom()?.name || '');
|
||||||
roomDescription = computed(() => this.currentRoom()?.description || '');
|
roomDescription = computed(() => this.currentRoom()?.description || '');
|
||||||
|
showRoomCompatibilityNotice = computed(() =>
|
||||||
|
this.inRoom() && !!this.signalServerCompatibilityError()
|
||||||
|
);
|
||||||
|
showRoomReconnectNotice = computed(() =>
|
||||||
|
this.inRoom()
|
||||||
|
&& !this.signalServerCompatibilityError()
|
||||||
|
&& (
|
||||||
|
this.isSignalServerReconnecting()
|
||||||
|
|| this.webrtc.shouldShowConnectionError()
|
||||||
|
|| this.isReconnecting()
|
||||||
|
)
|
||||||
|
);
|
||||||
private _showMenu = signal(false);
|
private _showMenu = signal(false);
|
||||||
showMenu = computed(() => this._showMenu());
|
showMenu = computed(() => this._showMenu());
|
||||||
showLeaveConfirm = signal(false);
|
showLeaveConfirm = signal(false);
|
||||||
|
inviteStatus = signal<string | null>(null);
|
||||||
|
creatingInvite = signal(false);
|
||||||
|
|
||||||
/** Minimize the Electron window. */
|
/** Minimize the Electron window. */
|
||||||
minimize() {
|
minimize() {
|
||||||
@@ -122,9 +146,44 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
/** Toggle the server dropdown menu. */
|
/** Toggle the server dropdown menu. */
|
||||||
toggleMenu() {
|
toggleMenu() {
|
||||||
|
this.inviteStatus.set(null);
|
||||||
this._showMenu.set(!this._showMenu());
|
this._showMenu.set(!this._showMenu());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a new invite link for the active room and copy it to the clipboard. */
|
||||||
|
async createInviteLink(): Promise<void> {
|
||||||
|
const room = this.currentRoom();
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (!room || !user || this.creatingInvite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creatingInvite.set(true);
|
||||||
|
this.inviteStatus.set('Creating invite link…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
||||||
|
room.id,
|
||||||
|
{
|
||||||
|
requesterUserId: user.id,
|
||||||
|
requesterDisplayName: user.displayName,
|
||||||
|
requesterRole: user.role
|
||||||
|
},
|
||||||
|
this.toSourceSelector(room)
|
||||||
|
));
|
||||||
|
|
||||||
|
await this.copyInviteLink(invite.inviteUrl);
|
||||||
|
this.inviteStatus.set('Invite link copied to clipboard.');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const inviteError = error as { error?: { error?: string } };
|
||||||
|
|
||||||
|
this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.');
|
||||||
|
} finally {
|
||||||
|
this.creatingInvite.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Leave the current server and navigate to the servers list. */
|
/** Leave the current server and navigate to the servers list. */
|
||||||
leaveServer() {
|
leaveServer() {
|
||||||
this.openLeaveConfirm();
|
this.openLeaveConfirm();
|
||||||
@@ -170,4 +229,44 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteUrl);
|
||||||
|
return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
|
||||||
|
textarea.value = inviteUrl;
|
||||||
|
textarea.setAttribute('readonly', 'true');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copied = document.execCommand('copy');
|
||||||
|
|
||||||
|
if (copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to prompt fallback */
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.prompt('Copy this invite link', inviteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
|
||||||
|
return {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
OnInit,
|
OnInit
|
||||||
OnDestroy
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import {
|
import {
|
||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
@@ -28,6 +26,7 @@ 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 { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
|
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
|
||||||
|
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-voice-controls',
|
selector: 'app-floating-voice-controls',
|
||||||
@@ -55,9 +54,10 @@ import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../
|
|||||||
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
||||||
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
||||||
*/
|
*/
|
||||||
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
export class FloatingVoiceControlsComponent implements OnInit {
|
||||||
private webrtcService = inject(WebRTCService);
|
private webrtcService = inject(WebRTCService);
|
||||||
private voiceSessionService = inject(VoiceSessionService);
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
@@ -75,8 +75,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
askScreenShareQuality = signal(true);
|
askScreenShareQuality = signal(true);
|
||||||
showScreenShareQualityDialog = signal(false);
|
showScreenShareQualityDialog = signal(false);
|
||||||
|
|
||||||
private stateSubscription: Subscription | null = null;
|
|
||||||
|
|
||||||
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
|
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Sync mute/deafen state from webrtc service
|
// Sync mute/deafen state from webrtc service
|
||||||
@@ -84,10 +82,15 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||||
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
|
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
|
||||||
this.syncScreenShareSettings();
|
this.syncScreenShareSettings();
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
const settings = loadVoiceSettingsFromStorage();
|
||||||
this.stateSubscription?.unsubscribe();
|
|
||||||
|
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
||||||
|
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||||
|
|
||||||
|
if (settings.outputDevice) {
|
||||||
|
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate back to the voice-connected server. */
|
/** Navigate back to the voice-connected server. */
|
||||||
@@ -117,6 +120,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
toggleDeafen(): void {
|
toggleDeafen(): void {
|
||||||
this.isDeafened.update((current) => !current);
|
this.isDeafened.update((current) => !current);
|
||||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||||
|
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||||
|
|
||||||
// When deafening, also mute
|
// When deafening, also mute
|
||||||
if (this.isDeafened() && !this.isMuted()) {
|
if (this.isDeafened() && !this.isMuted()) {
|
||||||
@@ -189,6 +193,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Disable voice
|
// Disable voice
|
||||||
this.webrtcService.disableVoice();
|
this.webrtcService.disableVoice();
|
||||||
|
this.voicePlayback.teardownAll();
|
||||||
|
this.voicePlayback.updateDeafened(false);
|
||||||
|
|
||||||
// Update user voice state in store
|
// Update user voice state in store
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|||||||
@@ -58,8 +58,31 @@ export class VoicePlaybackService {
|
|||||||
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput()
|
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput()
|
||||||
? 'default'
|
? 'default'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
void this.applyEffectiveOutputDeviceToAllPipelines();
|
void this.applyEffectiveOutputDeviceToAllPipelines();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.webrtc.onRemoteStream.subscribe(({ peerId }) => {
|
||||||
|
const voiceStream = this.webrtc.getRemoteVoiceStream(peerId);
|
||||||
|
|
||||||
|
if (!voiceStream) {
|
||||||
|
this.removeRemoteAudio(peerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.webrtc.onVoiceConnected.subscribe(() => {
|
||||||
|
const options = this.buildPlaybackOptions(true);
|
||||||
|
|
||||||
|
this.playPendingStreams(options);
|
||||||
|
this.ensureAllRemoteStreamsPlaying(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.webrtc.onPeerDisconnected.subscribe((peerId) => {
|
||||||
|
this.removeRemoteAudio(peerId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
||||||
@@ -158,6 +181,14 @@ export class VoicePlaybackService {
|
|||||||
this.pendingRemoteStreams.clear();
|
this.pendingRemoteStreams.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildPlaybackOptions(forceConnected = this.webrtc.isVoiceConnected()): PlaybackOptions {
|
||||||
|
return {
|
||||||
|
isConnected: forceConnected,
|
||||||
|
outputVolume: this.masterVolume,
|
||||||
|
isDeafened: this.deafened
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Web Audio graph for a remote peer:
|
* Build the Web Audio graph for a remote peer:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import {
|
import {
|
||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
@@ -76,7 +75,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
private voicePlayback = inject(VoicePlaybackService);
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
private remoteStreamSubscription: Subscription | null = null;
|
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
@@ -110,56 +108,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
isDeafened: this.isDeafened()
|
isDeafened: this.isDeafened()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private voiceConnectedSubscription: Subscription | null = null;
|
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
await this.loadAudioDevices();
|
await this.loadAudioDevices();
|
||||||
|
|
||||||
// Load persisted voice settings and apply
|
// Load persisted voice settings and apply
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
this.applySettingsToWebRTC();
|
this.applySettingsToWebRTC();
|
||||||
|
|
||||||
// Subscribe to remote streams to play audio from peers
|
|
||||||
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
|
||||||
({ peerId }) => {
|
|
||||||
const voiceStream = this.webrtcService.getRemoteVoiceStream(peerId);
|
|
||||||
|
|
||||||
if (!voiceStream) {
|
|
||||||
this.voicePlayback.removeRemoteAudio(peerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.voicePlayback.handleRemoteStream(peerId, voiceStream, this.playbackOptions());
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
|
|
||||||
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
|
||||||
const options = this.playbackOptions();
|
|
||||||
|
|
||||||
this.voicePlayback.playPendingStreams(options);
|
|
||||||
// Also ensure all remote streams from connected peers are playing
|
|
||||||
// This handles the case where streams were received while voice was "connected"
|
|
||||||
// from a previous session but audio elements weren't set up
|
|
||||||
this.voicePlayback.ensureAllRemoteStreamsPlaying(options);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up audio when peer disconnects
|
|
||||||
this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
|
|
||||||
this.voicePlayback.removeRemoteAudio(peerId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (this.isConnected()) {
|
if (!this.webrtcService.isVoiceConnected()) {
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.voicePlayback.teardownAll();
|
this.voicePlayback.teardownAll();
|
||||||
|
}
|
||||||
this.remoteStreamSubscription?.unsubscribe();
|
|
||||||
this.voiceConnectedSubscription?.unsubscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAudioDevices(): Promise<void> {
|
async loadAudioDevices(): Promise<void> {
|
||||||
@@ -304,6 +264,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
||||||
this.webrtcService.disableVoice();
|
this.webrtcService.disableVoice();
|
||||||
this.voicePlayback.teardownAll();
|
this.voicePlayback.teardownAll();
|
||||||
|
this.voicePlayback.updateDeafened(false);
|
||||||
|
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ export class DebugConsoleToolbarComponent {
|
|||||||
|
|
||||||
readonly tabs: ('logs' | 'network')[] = ['logs', 'network'];
|
readonly tabs: ('logs' | 'network')[] = ['logs', 'network'];
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!this.exportMenuOpen())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
if (!target.closest('[data-export-menu]'))
|
||||||
|
this.closeExportMenu();
|
||||||
|
}
|
||||||
|
|
||||||
setActiveTab(tab: 'logs' | 'network'): void {
|
setActiveTab(tab: 'logs' | 'network'): void {
|
||||||
this.activeTabChange.emit(tab);
|
this.activeTabChange.emit(tab);
|
||||||
}
|
}
|
||||||
@@ -138,17 +149,6 @@ export class DebugConsoleToolbarComponent {
|
|||||||
this.closeExportMenu();
|
this.closeExportMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
|
||||||
onDocumentClick(event: MouseEvent): void {
|
|
||||||
if (!this.exportMenuOpen())
|
|
||||||
return;
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
|
|
||||||
if (!target.closest('[data-export-menu]'))
|
|
||||||
this.closeExportMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDetachLabel(): string {
|
getDetachLabel(): string {
|
||||||
return this.detached() ? 'Dock' : 'Detach';
|
return this.detached() ? 'Dock' : 'Detach';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,16 @@
|
|||||||
<header class="border-b border-border p-5">
|
<header class="border-b border-border p-5">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="screen-share-source-picker-title" class="text-lg font-semibold text-foreground">
|
<h2
|
||||||
|
id="screen-share-source-picker-title"
|
||||||
|
class="text-lg font-semibold text-foreground"
|
||||||
|
>
|
||||||
Choose what to share
|
Choose what to share
|
||||||
</h2>
|
</h2>
|
||||||
<p id="screen-share-source-picker-description" class="mt-1 text-sm text-muted-foreground">
|
<p
|
||||||
|
id="screen-share-source-picker-description"
|
||||||
|
class="mt-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
Select a screen or window to start sharing.
|
Select a screen or window to start sharing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +61,11 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2" role="tablist" aria-label="Share source type">
|
<div
|
||||||
|
class="mt-4 flex flex-wrap gap-2"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Share source type"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
@@ -129,7 +139,11 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="screen-share-source-picker__preview">
|
<span class="screen-share-source-picker__preview">
|
||||||
<img [ngSrc]="source.thumbnail" [alt]="source.name" fill />
|
<img
|
||||||
|
[ngSrc]="source.thumbnail"
|
||||||
|
[alt]="source.name"
|
||||||
|
fill
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
|
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
|
||||||
@@ -156,13 +170,13 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
|
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-foreground">
|
<p class="text-sm font-medium text-foreground">No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available</p>
|
||||||
No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
{{ activeTab() === 'screen'
|
{{
|
||||||
|
activeTab() === 'screen'
|
||||||
? 'No displays were reported by Electron right now.'
|
? 'No displays were reported by Electron right now.'
|
||||||
: 'Restore the window you want to share and try again.' }}
|
: 'Restore the window you want to share and try again.'
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -256,7 +256,10 @@ function handleSyncBatch(
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
if (hasAttachmentMetaMap(event.attachments)) {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
attachments.registerSyncedAttachments(event.attachments);
|
attachments.registerSyncedAttachments(
|
||||||
|
event.attachments,
|
||||||
|
Object.fromEntries(event.messages.map((message) => [message.id, message.roomId]))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return from(processSyncBatch(event, db, attachments)).pipe(
|
return from(processSyncBatch(event, db, attachments)).pipe(
|
||||||
@@ -277,6 +280,8 @@ async function processSyncBatch(
|
|||||||
const toUpsert: Message[] = [];
|
const toUpsert: Message[] = [];
|
||||||
|
|
||||||
for (const incoming of event.messages) {
|
for (const incoming of event.messages) {
|
||||||
|
attachments.rememberMessageRoom(incoming.id, incoming.roomId);
|
||||||
|
|
||||||
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
||||||
|
|
||||||
if (incoming.isDeleted) {
|
if (incoming.isDeleted) {
|
||||||
@@ -292,40 +297,31 @@ async function processSyncBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasAttachmentMetaMap(event.attachments)) {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
requestMissingImages(event.attachments, attachments);
|
queueWatchedAttachmentDownloads(event.attachments, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
return toUpsert;
|
return toUpsert;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-requests any unavailable image attachments from any connected peer. */
|
/** Queue best-effort auto-downloads for watched-room attachments. */
|
||||||
function requestMissingImages(
|
function queueWatchedAttachmentDownloads(
|
||||||
attachmentMap: AttachmentMetaMap,
|
attachmentMap: AttachmentMetaMap,
|
||||||
attachments: AttachmentService
|
attachments: AttachmentService
|
||||||
): void {
|
): void {
|
||||||
for (const [msgId, metas] of Object.entries(attachmentMap)) {
|
for (const msgId of Object.keys(attachmentMap)) {
|
||||||
for (const meta of metas) {
|
attachments.queueAutoDownloadsForMessage(msgId);
|
||||||
if (!meta.isImage)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const atts = attachments.getForMessage(msgId);
|
|
||||||
const matchingAttachment = atts.find((attachment) => attachment.id === meta.id);
|
|
||||||
|
|
||||||
if (
|
|
||||||
matchingAttachment &&
|
|
||||||
!matchingAttachment.available &&
|
|
||||||
!(matchingAttachment.receivedBytes && matchingAttachment.receivedBytes > 0)
|
|
||||||
) {
|
|
||||||
attachments.requestImageFromAnyPeer(msgId, matchingAttachment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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: IncomingMessageEvent,
|
event: IncomingMessageEvent,
|
||||||
{ db, debugging, currentUser }: IncomingMessageContext
|
{
|
||||||
|
db,
|
||||||
|
debugging,
|
||||||
|
attachments,
|
||||||
|
currentUser
|
||||||
|
}: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const msg = event.message;
|
const msg = event.message;
|
||||||
|
|
||||||
@@ -340,6 +336,8 @@ function handleChatMessage(
|
|||||||
if (isOwnMessage)
|
if (isOwnMessage)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
|
attachments.rememberMessageRoom(msg.id, msg.roomId);
|
||||||
|
|
||||||
trackBackgroundOperation(
|
trackBackgroundOperation(
|
||||||
db.saveMessage(msg),
|
db.saveMessage(msg),
|
||||||
debugging,
|
debugging,
|
||||||
@@ -492,6 +490,11 @@ function handleFileAnnounce(
|
|||||||
{ attachments }: IncomingMessageContext
|
{ attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
attachments.handleFileAnnounce(event);
|
attachments.handleFileAnnounce(event);
|
||||||
|
|
||||||
|
if (event.messageId) {
|
||||||
|
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
|
||||||
|
}
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Sync-lifecycle effects for the messages store slice.
|
* Sync-lifecycle effects for the messages store slice.
|
||||||
*
|
*
|
||||||
* These effects manage the periodic sync polling, peer-connect
|
* These effects manage the periodic sync polling, peer-connect
|
||||||
* handshakes, and join-room kickoff that keep message databases
|
* handshakes, and room-activation kickoff that keep message databases
|
||||||
* in sync across peers.
|
* in sync across peers.
|
||||||
*
|
*
|
||||||
* Extracted from the monolithic MessagesEffects to keep each
|
* Extracted from the monolithic MessagesEffects to keep each
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
exhaustMap,
|
exhaustMap,
|
||||||
switchMap,
|
switchMap,
|
||||||
repeat,
|
repeat,
|
||||||
takeUntil
|
startWith
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
import { RoomsActions } from '../rooms/rooms.actions';
|
import { RoomsActions } from '../rooms/rooms.actions';
|
||||||
@@ -103,13 +103,13 @@ export class MessagesSyncEffects {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the user joins a room, sends a summary and inventory
|
* When the user joins or views a room, sends a summary and inventory
|
||||||
* request to every already-connected peer.
|
* request to every already-connected peer.
|
||||||
*/
|
*/
|
||||||
joinRoomSyncKickoff$ = createEffect(
|
roomActivationSyncKickoff$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.joinRoomSuccess),
|
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
mergeMap(([{ room }, currentRoom]) => {
|
mergeMap(([{ room }, currentRoom]) => {
|
||||||
const activeRoom = currentRoom || room;
|
const activeRoom = currentRoom || room;
|
||||||
@@ -152,11 +152,30 @@ export class MessagesSyncEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the polling cadence when the active room changes so the next
|
||||||
|
* room does not inherit a stale slow-poll delay.
|
||||||
|
*/
|
||||||
|
resetPeriodicSyncOnRoomActivation$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||||
|
tap(() => {
|
||||||
|
this.lastSyncClean = false;
|
||||||
|
this.syncReset$.next();
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ dispatch: false }
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alternates between fast (10 s) and slow (15 min) sync intervals.
|
* Alternates between fast (10 s) and slow (15 min) sync intervals.
|
||||||
* Sends inventory requests to all connected peers.
|
* Sends inventory requests to all connected peers for the active room.
|
||||||
*/
|
*/
|
||||||
periodicSyncPoll$ = createEffect(() =>
|
periodicSyncPoll$ = createEffect(() =>
|
||||||
|
this.syncReset$.pipe(
|
||||||
|
startWith(undefined),
|
||||||
|
switchMap(() =>
|
||||||
timer(SYNC_POLL_FAST_MS).pipe(
|
timer(SYNC_POLL_FAST_MS).pipe(
|
||||||
repeat({
|
repeat({
|
||||||
delay: () =>
|
delay: () =>
|
||||||
@@ -164,7 +183,6 @@ export class MessagesSyncEffects {
|
|||||||
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
|
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
takeUntil(this.syncReset$),
|
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
filter(
|
filter(
|
||||||
([, room]) =>
|
([, room]) =>
|
||||||
@@ -210,6 +228,8 @@ export class MessagesSyncEffects {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export class MessagesEffects {
|
|||||||
mergeMap(async (messages) => {
|
mergeMap(async (messages) => {
|
||||||
const hydrated = await hydrateMessages(messages, this.db);
|
const hydrated = await hydrateMessages(messages, this.db);
|
||||||
|
|
||||||
|
for (const message of hydrated) {
|
||||||
|
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.attachments.requestAutoDownloadsForRoom(roomId);
|
||||||
|
|
||||||
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
@@ -104,6 +110,8 @@ export class MessagesEffects {
|
|||||||
replyToId
|
replyToId
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
||||||
|
|
||||||
this.trackBackgroundOperation(
|
this.trackBackgroundOperation(
|
||||||
this.db.saveMessage(message),
|
this.db.saveMessage(message),
|
||||||
'Failed to persist outgoing chat message',
|
'Failed to persist outgoing chat message',
|
||||||
|
|||||||
@@ -25,18 +25,26 @@ export const RoomsActions = createActionGroup({
|
|||||||
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
|
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
|
||||||
'Search Servers Failure': props<{ error: string }>(),
|
'Search Servers Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'Create Room': props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>(),
|
'Create Room': props<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
topic?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
|
password?: string;
|
||||||
|
sourceId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
}>(),
|
||||||
'Create Room Success': props<{ room: Room }>(),
|
'Create Room Success': props<{ room: Room }>(),
|
||||||
'Create Room Failure': props<{ error: string }>(),
|
'Create Room Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'Join Room': props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>(),
|
'Join Room': props<{ roomId: string; password?: string; serverInfo?: Partial<ServerInfo> & { name: string } }>(),
|
||||||
'Join Room Success': props<{ room: Room }>(),
|
'Join Room Success': props<{ room: Room }>(),
|
||||||
'Join Room Failure': props<{ error: string }>(),
|
'Join Room Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'Leave Room': emptyProps(),
|
'Leave Room': emptyProps(),
|
||||||
'Leave Room Success': emptyProps(),
|
'Leave Room Success': emptyProps(),
|
||||||
|
|
||||||
'View Server': props<{ room: Room }>(),
|
'View Server': props<{ room: Room; skipBanCheck?: boolean }>(),
|
||||||
'View Server Success': props<{ room: Room }>(),
|
'View Server Success': props<{ room: Room }>(),
|
||||||
|
|
||||||
'Delete Room': props<{ roomId: string }>(),
|
'Delete Room': props<{ roomId: string }>(),
|
||||||
@@ -67,6 +75,8 @@ export const RoomsActions = createActionGroup({
|
|||||||
'Rename Channel': props<{ channelId: string; name: string }>(),
|
'Rename Channel': props<{ channelId: string; name: string }>(),
|
||||||
|
|
||||||
'Clear Search Results': emptyProps(),
|
'Clear Search Results': emptyProps(),
|
||||||
'Set Connecting': props<{ isConnecting: boolean }>()
|
'Set Connecting': props<{ isConnecting: boolean }>(),
|
||||||
|
'Set Signal Server Reconnecting': props<{ isReconnecting: boolean }>(),
|
||||||
|
'Set Signal Server Compatibility Error': props<{ message: string | null }>()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
|||||||
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
import { selectCurrentRoom, selectSavedRooms } from './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 { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import {
|
||||||
|
CLIENT_UPDATE_REQUIRED_MESSAGE,
|
||||||
|
ServerDirectoryService,
|
||||||
|
ServerSourceSelector
|
||||||
|
} from '../../core/services/server-directory.service';
|
||||||
import {
|
import {
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
@@ -44,6 +48,7 @@ import {
|
|||||||
} 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';
|
||||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
|
import { ROOM_URL_PATTERN } from '../../core/constants';
|
||||||
import {
|
import {
|
||||||
findRoomMember,
|
findRoomMember,
|
||||||
removeRoomMember,
|
removeRoomMember,
|
||||||
@@ -95,6 +100,7 @@ function isWrongServer(
|
|||||||
|
|
||||||
interface RoomPresenceSignalingMessage {
|
interface RoomPresenceSignalingMessage {
|
||||||
type: string;
|
type: string;
|
||||||
|
reason?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
users?: { oderId: string; displayName: string }[];
|
users?: { oderId: string; displayName: string }[];
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
@@ -139,7 +145,6 @@ export class RoomsEffects {
|
|||||||
searchServers$ = createEffect(() =>
|
searchServers$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.searchServers),
|
ofType(RoomsActions.searchServers),
|
||||||
debounceTime(300),
|
|
||||||
switchMap(({ query }) =>
|
switchMap(({ query }) =>
|
||||||
this.serverDirectory.searchServers(query).pipe(
|
this.serverDirectory.searchServers(query).pipe(
|
||||||
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
||||||
@@ -149,27 +154,69 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Reconnects saved rooms so joined servers stay online while the app is running. */
|
||||||
|
keepSavedRoomsConnected$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(
|
||||||
|
RoomsActions.loadRoomsSuccess,
|
||||||
|
RoomsActions.forgetRoomSuccess,
|
||||||
|
RoomsActions.deleteRoomSuccess,
|
||||||
|
UsersActions.loadCurrentUserSuccess,
|
||||||
|
UsersActions.setCurrentUser
|
||||||
|
),
|
||||||
|
withLatestFrom(
|
||||||
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
|
),
|
||||||
|
tap(([
|
||||||
|
, user,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
|
]) => {
|
||||||
|
this.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ dispatch: false }
|
||||||
|
);
|
||||||
|
|
||||||
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
||||||
createRoom$ = createEffect(() =>
|
createRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.createRoom),
|
ofType(RoomsActions.createRoom),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
switchMap(([{ name, description, topic, isPrivate, password }, currentUser]) => {
|
switchMap(([{ name, description, topic, isPrivate, password, sourceId, sourceUrl }, currentUser]) => {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
|
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allEndpoints = this.serverDirectory.servers();
|
||||||
|
const activeEndpoints = this.serverDirectory.activeServers();
|
||||||
|
const selectedEndpoint = allEndpoints.find((endpoint) =>
|
||||||
|
(sourceId && endpoint.id === sourceId)
|
||||||
|
|| (!!sourceUrl && endpoint.url === sourceUrl)
|
||||||
|
);
|
||||||
|
const endpoint = selectedEndpoint
|
||||||
|
?? activeEndpoints[0]
|
||||||
|
?? allEndpoints[0]
|
||||||
|
?? null;
|
||||||
|
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
topic,
|
topic,
|
||||||
hostId: currentUser.id,
|
hostId: currentUser.id,
|
||||||
|
password: normalizedPassword || undefined,
|
||||||
|
hasPassword: normalizedPassword.length > 0,
|
||||||
isPrivate: isPrivate ?? false,
|
isPrivate: isPrivate ?? false,
|
||||||
password,
|
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
userCount: 1,
|
userCount: 1,
|
||||||
maxUsers: 50
|
maxUsers: 50,
|
||||||
|
sourceId: endpoint?.id,
|
||||||
|
sourceName: endpoint?.name,
|
||||||
|
sourceUrl: endpoint?.url
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to local DB
|
// Save to local DB
|
||||||
@@ -184,11 +231,17 @@ export class RoomsEffects {
|
|||||||
ownerId: currentUser.id,
|
ownerId: currentUser.id,
|
||||||
ownerPublicKey: currentUser.oderId,
|
ownerPublicKey: currentUser.oderId,
|
||||||
hostName: currentUser.displayName,
|
hostName: currentUser.displayName,
|
||||||
|
password: normalizedPassword || null,
|
||||||
|
hasPassword: normalizedPassword.length > 0,
|
||||||
isPrivate: room.isPrivate,
|
isPrivate: room.isPrivate,
|
||||||
userCount: 1,
|
userCount: 1,
|
||||||
maxUsers: room.maxUsers || 50,
|
maxUsers: room.maxUsers || 50,
|
||||||
tags: []
|
tags: []
|
||||||
})
|
}, endpoint ? {
|
||||||
|
sourceId: endpoint.id,
|
||||||
|
sourceUrl: endpoint.url
|
||||||
|
} : undefined
|
||||||
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
return of(RoomsActions.createRoomSuccess({ room }));
|
return of(RoomsActions.createRoomSuccess({ room }));
|
||||||
@@ -216,8 +269,35 @@ export class RoomsEffects {
|
|||||||
// First check local DB
|
// First check local DB
|
||||||
return from(this.db.getRoom(roomId)).pipe(
|
return from(this.db.getRoom(roomId)).pipe(
|
||||||
switchMap((room) => {
|
switchMap((room) => {
|
||||||
|
const sourceSelector = serverInfo
|
||||||
|
? {
|
||||||
|
sourceId: serverInfo.sourceId,
|
||||||
|
sourceUrl: serverInfo.sourceUrl
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
return of(RoomsActions.joinRoomSuccess({ room }));
|
const resolvedRoom: Room = {
|
||||||
|
...room,
|
||||||
|
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
|
||||||
|
sourceId: serverInfo?.sourceId ?? room.sourceId,
|
||||||
|
sourceName: serverInfo?.sourceName ?? room.sourceName,
|
||||||
|
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
|
||||||
|
hasPassword:
|
||||||
|
typeof serverInfo?.hasPassword === 'boolean'
|
||||||
|
? serverInfo.hasPassword
|
||||||
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.updateRoom(room.id, {
|
||||||
|
sourceId: resolvedRoom.sourceId,
|
||||||
|
sourceName: resolvedRoom.sourceName,
|
||||||
|
sourceUrl: resolvedRoom.sourceUrl,
|
||||||
|
hasPassword: resolvedRoom.hasPassword,
|
||||||
|
isPrivate: resolvedRoom.isPrivate
|
||||||
|
});
|
||||||
|
|
||||||
|
return of(RoomsActions.joinRoomSuccess({ room: resolvedRoom }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in local DB but we have server info from search, create a room entry
|
// If not in local DB but we have server info from search, create a room entry
|
||||||
@@ -227,11 +307,14 @@ export class RoomsEffects {
|
|||||||
name: serverInfo.name,
|
name: serverInfo.name,
|
||||||
description: serverInfo.description,
|
description: serverInfo.description,
|
||||||
hostId: '', // Unknown, will be determined via signaling
|
hostId: '', // Unknown, will be determined via signaling
|
||||||
isPrivate: !!password,
|
hasPassword: !!serverInfo.hasPassword,
|
||||||
password,
|
isPrivate: !!serverInfo.isPrivate,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
userCount: 1,
|
userCount: 1,
|
||||||
maxUsers: 50
|
maxUsers: 50,
|
||||||
|
sourceId: serverInfo.sourceId,
|
||||||
|
sourceName: serverInfo.sourceName,
|
||||||
|
sourceUrl: serverInfo.sourceUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to local DB for future reference
|
// Save to local DB for future reference
|
||||||
@@ -240,7 +323,7 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to get room info from server
|
// Try to get room info from server
|
||||||
return this.serverDirectory.getServer(roomId).pipe(
|
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
|
||||||
switchMap((serverData) => {
|
switchMap((serverData) => {
|
||||||
if (serverData) {
|
if (serverData) {
|
||||||
const newRoom: Room = {
|
const newRoom: Room = {
|
||||||
@@ -248,11 +331,14 @@ export class RoomsEffects {
|
|||||||
name: serverData.name,
|
name: serverData.name,
|
||||||
description: serverData.description,
|
description: serverData.description,
|
||||||
hostId: serverData.ownerId || '',
|
hostId: serverData.ownerId || '',
|
||||||
|
hasPassword: !!serverData.hasPassword,
|
||||||
isPrivate: serverData.isPrivate,
|
isPrivate: serverData.isPrivate,
|
||||||
password,
|
|
||||||
createdAt: serverData.createdAt || Date.now(),
|
createdAt: serverData.createdAt || Date.now(),
|
||||||
userCount: serverData.userCount,
|
userCount: serverData.userCount,
|
||||||
maxUsers: serverData.maxUsers
|
maxUsers: serverData.maxUsers,
|
||||||
|
sourceId: serverData.sourceId,
|
||||||
|
sourceName: serverData.sourceName,
|
||||||
|
sourceUrl: serverData.sourceUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.saveRoom(newRoom);
|
this.db.saveRoom(newRoom);
|
||||||
@@ -278,28 +364,15 @@ export class RoomsEffects {
|
|||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
|
||||||
tap(([{ room }, user]) => {
|
tap(([
|
||||||
const wsUrl = this.serverDirectory.getWebSocketUrl();
|
{ room },
|
||||||
const oderId = user?.oderId || this.webrtc.peerId();
|
user,
|
||||||
const displayName = user?.displayName || 'Anonymous';
|
savedRooms
|
||||||
|
]) => {
|
||||||
// Check if already connected to signaling server
|
void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
|
||||||
if (this.webrtc.isConnected()) {
|
showCompatibilityError: true
|
||||||
this.webrtc.setCurrentServer(room.id);
|
|
||||||
this.webrtc.switchServer(room.id, oderId);
|
|
||||||
} else {
|
|
||||||
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
|
||||||
next: (connected) => {
|
|
||||||
if (connected) {
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
|
||||||
this.webrtc.identify(oderId, displayName);
|
|
||||||
this.webrtc.joinRoom(room.id, oderId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
this.router.navigate(['/room', room.id]);
|
||||||
})
|
})
|
||||||
@@ -311,27 +384,38 @@ export class RoomsEffects {
|
|||||||
viewServer$ = createEffect(() =>
|
viewServer$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.viewServer),
|
ofType(RoomsActions.viewServer),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
|
||||||
switchMap(([{ room }, user]) => {
|
switchMap(([
|
||||||
|
{ room, skipBanCheck },
|
||||||
|
user,
|
||||||
|
savedRooms
|
||||||
|
]) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activateViewedRoom = () => {
|
||||||
|
const oderId = user.oderId || this.webrtc.peerId();
|
||||||
|
|
||||||
|
void this.connectToRoomSignaling(room, user, oderId, savedRooms, {
|
||||||
|
showCompatibilityError: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.router.navigate(['/room', room.id]);
|
||||||
|
return of(RoomsActions.viewServerSuccess({ room }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (skipBanCheck) {
|
||||||
|
return activateViewedRoom();
|
||||||
|
}
|
||||||
|
|
||||||
return from(this.getBlockedRoomAccessActions(room.id, user)).pipe(
|
return from(this.getBlockedRoomAccessActions(room.id, user)).pipe(
|
||||||
switchMap((blockedActions) => {
|
switchMap((blockedActions) => {
|
||||||
if (blockedActions.length > 0) {
|
if (blockedActions.length > 0) {
|
||||||
return from(blockedActions);
|
return from(blockedActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oderId = user.oderId || this.webrtc.peerId();
|
return activateViewedRoom();
|
||||||
|
|
||||||
if (this.webrtc.isConnected()) {
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
|
||||||
this.webrtc.switchServer(room.id, oderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
|
||||||
return of(RoomsActions.viewServerSuccess({ room }));
|
|
||||||
}),
|
}),
|
||||||
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
||||||
);
|
);
|
||||||
@@ -432,8 +516,12 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
this.serverDirectory.updateServer(roomId, {
|
this.serverDirectory.updateServer(roomId, {
|
||||||
currentOwnerId: currentUser.id,
|
currentOwnerId: currentUser.id,
|
||||||
|
actingRole: 'host',
|
||||||
ownerId: nextHostId,
|
ownerId: nextHostId,
|
||||||
ownerPublicKey: nextHostOderId
|
ownerPublicKey: nextHostOderId
|
||||||
|
}, {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
error: () => {}
|
error: () => {}
|
||||||
});
|
});
|
||||||
@@ -449,6 +537,15 @@ export class RoomsEffects {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentUser && room) {
|
||||||
|
this.serverDirectory.notifyLeave(roomId, currentUser.id, {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
}).subscribe({
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Delete from local DB
|
// Delete from local DB
|
||||||
this.db.deleteRoom(roomId);
|
this.db.deleteRoom(roomId);
|
||||||
|
|
||||||
@@ -485,10 +582,11 @@ export class RoomsEffects {
|
|||||||
if (!room)
|
if (!room)
|
||||||
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
|
||||||
|
|
||||||
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
|
||||||
const canManageCurrentRoom = currentRoom?.id === room.id && (currentUser.role === 'host' || currentUser.role === 'admin');
|
const isOwner = currentUserRole === 'host';
|
||||||
|
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin';
|
||||||
|
|
||||||
if (!isOwner && !canManageCurrentRoom) {
|
if (!canManageRoom) {
|
||||||
return of(
|
return of(
|
||||||
RoomsActions.updateRoomSettingsFailure({
|
RoomsActions.updateRoomSettingsFailure({
|
||||||
error: 'Permission denied'
|
error: 'Permission denied'
|
||||||
@@ -496,30 +594,57 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(settings, 'password');
|
||||||
|
const normalizedPassword = typeof settings.password === 'string' ? settings.password.trim() : undefined;
|
||||||
|
const nextHasPassword = typeof settings.hasPassword === 'boolean'
|
||||||
|
? settings.hasPassword
|
||||||
|
: (hasPasswordUpdate
|
||||||
|
? !!normalizedPassword
|
||||||
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password));
|
||||||
const updatedSettings: RoomSettings = {
|
const updatedSettings: RoomSettings = {
|
||||||
name: settings.name ?? room.name,
|
name: settings.name ?? room.name,
|
||||||
description: settings.description ?? room.description,
|
description: settings.description ?? room.description,
|
||||||
topic: settings.topic ?? room.topic,
|
topic: settings.topic ?? room.topic,
|
||||||
isPrivate: settings.isPrivate ?? room.isPrivate,
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
||||||
password: settings.password ?? room.password,
|
password: hasPasswordUpdate ? (normalizedPassword || '') : room.password,
|
||||||
|
hasPassword: nextHasPassword,
|
||||||
maxUsers: settings.maxUsers ?? room.maxUsers
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
||||||
};
|
};
|
||||||
|
const localRoomUpdates: Partial<Room> = {
|
||||||
|
...updatedSettings,
|
||||||
|
password: hasPasswordUpdate ? (normalizedPassword || undefined) : room.password,
|
||||||
|
hasPassword: nextHasPassword
|
||||||
|
};
|
||||||
|
const sharedSettings: RoomSettings = {
|
||||||
|
name: updatedSettings.name,
|
||||||
|
description: updatedSettings.description,
|
||||||
|
topic: updatedSettings.topic,
|
||||||
|
isPrivate: updatedSettings.isPrivate,
|
||||||
|
hasPassword: nextHasPassword,
|
||||||
|
maxUsers: updatedSettings.maxUsers,
|
||||||
|
password: hasPasswordUpdate ? (normalizedPassword || '') : undefined
|
||||||
|
};
|
||||||
|
|
||||||
this.db.updateRoom(room.id, updatedSettings);
|
this.db.updateRoom(room.id, localRoomUpdates);
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'room-settings-update',
|
type: 'room-settings-update',
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
settings: updatedSettings
|
settings: sharedSettings
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isOwner) {
|
if (canManageRoom) {
|
||||||
this.serverDirectory.updateServer(room.id, {
|
this.serverDirectory.updateServer(room.id, {
|
||||||
currentOwnerId: currentUser.id,
|
currentOwnerId: currentUser.id,
|
||||||
|
actingRole: currentUserRole ?? undefined,
|
||||||
name: updatedSettings.name,
|
name: updatedSettings.name,
|
||||||
description: updatedSettings.description,
|
description: updatedSettings.description,
|
||||||
isPrivate: updatedSettings.isPrivate,
|
isPrivate: updatedSettings.isPrivate,
|
||||||
maxUsers: updatedSettings.maxUsers
|
maxUsers: updatedSettings.maxUsers,
|
||||||
|
password: hasPasswordUpdate ? (normalizedPassword || null) : undefined
|
||||||
|
}, {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
error: () => {}
|
error: () => {}
|
||||||
});
|
});
|
||||||
@@ -713,7 +838,11 @@ export class RoomsEffects {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return [UsersActions.clearUsers(), ...joinActions];
|
return [
|
||||||
|
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
||||||
|
UsersActions.clearUsers(),
|
||||||
|
...joinActions
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'user_joined': {
|
case 'user_joined': {
|
||||||
@@ -729,6 +858,7 @@ export class RoomsEffects {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
||||||
})
|
})
|
||||||
@@ -743,7 +873,17 @@ export class RoomsEffects {
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
||||||
return [UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: false }), UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'access_denied': {
|
||||||
|
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -945,14 +1085,20 @@ export class RoomsEffects {
|
|||||||
description: typeof room.description === 'string' ? room.description : undefined,
|
description: typeof room.description === 'string' ? room.description : undefined,
|
||||||
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
||||||
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
|
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
|
||||||
password: typeof room.password === 'string' ? room.password : undefined,
|
hasPassword:
|
||||||
|
typeof room.hasPassword === 'boolean'
|
||||||
|
? room.hasPassword
|
||||||
|
: (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined),
|
||||||
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
|
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
|
||||||
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
|
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
|
||||||
icon: typeof room.icon === 'string' ? room.icon : undefined,
|
icon: typeof room.icon === 'string' ? room.icon : undefined,
|
||||||
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
|
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
|
||||||
permissions: room.permissions ? { ...room.permissions } : undefined,
|
permissions: room.permissions ? { ...room.permissions } : undefined,
|
||||||
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
||||||
members: Array.isArray(room.members) ? room.members : undefined
|
members: Array.isArray(room.members) ? room.members : undefined,
|
||||||
|
sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined,
|
||||||
|
sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined,
|
||||||
|
sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,7 +1151,7 @@ export class RoomsEffects {
|
|||||||
this.webrtc.sendToPeer(fromPeerId, {
|
this.webrtc.sendToPeer(fromPeerId, {
|
||||||
type: 'server-state-full',
|
type: 'server-state-full',
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
room,
|
room: this.sanitizeRoomSnapshot(room),
|
||||||
bans
|
bans
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -1075,7 +1221,11 @@ export class RoomsEffects {
|
|||||||
description: settings.description ?? room.description,
|
description: settings.description ?? room.description,
|
||||||
topic: settings.topic ?? room.topic,
|
topic: settings.topic ?? room.topic,
|
||||||
isPrivate: settings.isPrivate ?? room.isPrivate,
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
||||||
password: settings.password ?? room.password,
|
password: settings.password === '' ? undefined : room.password,
|
||||||
|
hasPassword:
|
||||||
|
typeof settings.hasPassword === 'boolean'
|
||||||
|
? settings.hasPassword
|
||||||
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
|
||||||
maxUsers: settings.maxUsers ?? room.maxUsers
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1196,6 +1346,190 @@ export class RoomsEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private async connectToRoomSignaling(
|
||||||
|
room: Room,
|
||||||
|
user: User | null,
|
||||||
|
resolvedOderId?: string,
|
||||||
|
savedRooms: Room[] = [],
|
||||||
|
options: { showCompatibilityError?: boolean } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
|
||||||
|
const compatibilitySelector = this.resolveCompatibilitySelector(room);
|
||||||
|
const isCompatible = compatibilitySelector === null
|
||||||
|
? true
|
||||||
|
: await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector);
|
||||||
|
|
||||||
|
if (!isCompatible) {
|
||||||
|
if (shouldShowCompatibilityError) {
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.setSignalServerCompatibilityError({ message: CLIENT_UPDATE_REQUIRED_MESSAGE })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowCompatibilityError) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = this.serverDirectory.getWebSocketUrl({
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
});
|
||||||
|
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
|
||||||
|
const displayName = user?.displayName || 'Anonymous';
|
||||||
|
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
||||||
|
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
||||||
|
const joinCurrentEndpointRooms = () => {
|
||||||
|
this.webrtc.setCurrentServer(room.id);
|
||||||
|
this.webrtc.identify(oderId, displayName, wsUrl);
|
||||||
|
|
||||||
|
for (const backgroundRoom of backgroundRooms) {
|
||||||
|
if (!this.webrtc.hasJoinedServer(backgroundRoom.id)) {
|
||||||
|
this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.webrtc.hasJoinedServer(room.id)) {
|
||||||
|
this.webrtc.switchServer(room.id, oderId, wsUrl);
|
||||||
|
} else {
|
||||||
|
this.webrtc.joinRoom(room.id, oderId, wsUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.webrtc.isSignalingConnectedTo(wsUrl)) {
|
||||||
|
joinCurrentEndpointRooms();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
||||||
|
next: (connected) => {
|
||||||
|
if (!connected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
joinCurrentEndpointRooms();
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSavedRoomConnections(user: User | null, currentRoom: Room | null, savedRooms: Room[]): void {
|
||||||
|
if (!user || savedRooms.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchedRoomId = this.extractRoomIdFromUrl(this.router.url);
|
||||||
|
const roomsToSync = currentRoom ? this.includeRoom(savedRooms, currentRoom) : savedRooms;
|
||||||
|
const roomsBySignalingUrl = new Map<string, Room[]>();
|
||||||
|
|
||||||
|
for (const room of roomsToSync) {
|
||||||
|
const wsUrl = this.serverDirectory.getWebSocketUrl({
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
});
|
||||||
|
const groupedRooms = roomsBySignalingUrl.get(wsUrl) ?? [];
|
||||||
|
|
||||||
|
if (!groupedRooms.some((groupedRoom) => groupedRoom.id === room.id)) {
|
||||||
|
groupedRooms.push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
roomsBySignalingUrl.set(wsUrl, groupedRooms);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const groupedRooms of roomsBySignalingUrl.values()) {
|
||||||
|
const preferredRoom = groupedRooms.find((room) => room.id === watchedRoomId)
|
||||||
|
?? (currentRoom && groupedRooms.some((room) => room.id === currentRoom.id)
|
||||||
|
? currentRoom
|
||||||
|
: null)
|
||||||
|
?? groupedRooms[0]
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (!preferredRoom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowCompatibilityError = preferredRoom.id === watchedRoomId
|
||||||
|
|| (!!currentRoom && preferredRoom.id === currentRoom.id);
|
||||||
|
|
||||||
|
void this.connectToRoomSignaling(preferredRoom, user, user.oderId || this.webrtc.peerId(), roomsToSync, {
|
||||||
|
showCompatibilityError: shouldShowCompatibilityError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCompatibilitySelector(room: Room): ServerSourceSelector | undefined | null {
|
||||||
|
if (room.sourceId) {
|
||||||
|
const endpointById = this.serverDirectory.servers().find((entry) => entry.id === room.sourceId);
|
||||||
|
|
||||||
|
if (endpointById) {
|
||||||
|
return { sourceId: room.sourceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.sourceUrl && this.serverDirectory.findServerByUrl(room.sourceUrl)) {
|
||||||
|
return { sourceUrl: room.sourceUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.sourceUrl) {
|
||||||
|
return this.serverDirectory.findServerByUrl(room.sourceUrl)
|
||||||
|
? { sourceUrl: room.sourceUrl }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private includeRoom(rooms: Room[], room: Room): Room[] {
|
||||||
|
return rooms.some((candidate) => candidate.id === room.id)
|
||||||
|
? rooms
|
||||||
|
: [...rooms, room];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRoomsForSignalingUrl(rooms: Room[], wsUrl: string): Room[] {
|
||||||
|
const seenRoomIds = new Set<string>();
|
||||||
|
const matchingRooms: Room[] = [];
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (seenRoomIds.has(room.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.serverDirectory.getWebSocketUrl({
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
}) !== wsUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenRoomIds.add(room.id);
|
||||||
|
matchingRooms.push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingRooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractRoomIdFromUrl(url: string): string | null {
|
||||||
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
|
|
||||||
|
return roomMatch ? roomMatch[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
||||||
|
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
|
||||||
|
return 'host';
|
||||||
|
|
||||||
|
if (currentRoom?.id === room.id && currentUser.role)
|
||||||
|
return currentUser.role;
|
||||||
|
|
||||||
|
return findRoomMember(room.members ?? [], currentUser.id)?.role
|
||||||
|
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
private getPersistedCurrentUserId(): string | null {
|
private getPersistedCurrentUserId(): string | null {
|
||||||
return localStorage.getItem('metoyou_currentUserId');
|
return localStorage.getItem('metoyou_currentUserId');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function deduplicateRooms(rooms: Room[]): Room[] {
|
|||||||
function enrichRoom(room: Room): Room {
|
function enrichRoom(room: Room): Room {
|
||||||
return {
|
return {
|
||||||
...room,
|
...room,
|
||||||
|
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||||
channels: room.channels || defaultChannels(),
|
channels: room.channels || defaultChannels(),
|
||||||
members: pruneRoomMembers(room.members || [])
|
members: pruneRoomMembers(room.members || [])
|
||||||
};
|
};
|
||||||
@@ -81,6 +82,10 @@ export interface RoomsState {
|
|||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
/** Whether the user is connected to a room. */
|
/** Whether the user is connected to a room. */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
/** Whether the current room is using locally cached data while reconnecting. */
|
||||||
|
isSignalServerReconnecting: boolean;
|
||||||
|
/** Banner message when the viewed room's signaling endpoint is incompatible. */
|
||||||
|
signalServerCompatibilityError: string | null;
|
||||||
/** Whether rooms are being loaded from local storage. */
|
/** Whether rooms are being loaded from local storage. */
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** Most recent error message, if any. */
|
/** Most recent error message, if any. */
|
||||||
@@ -97,6 +102,8 @@ export const initialState: RoomsState = {
|
|||||||
isSearching: false,
|
isSearching: false,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
|
isSignalServerReconnecting: false,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
activeChannelId: 'general'
|
activeChannelId: 'general'
|
||||||
@@ -147,6 +154,7 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.createRoom, (state) => ({
|
on(RoomsActions.createRoom, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
error: null
|
error: null
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@@ -158,6 +166,8 @@ export const roomsReducer = createReducer(
|
|||||||
currentRoom: enriched,
|
currentRoom: enriched,
|
||||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
|
isSignalServerReconnecting: false,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
activeChannelId: 'general'
|
activeChannelId: 'general'
|
||||||
};
|
};
|
||||||
@@ -173,6 +183,7 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.joinRoom, (state) => ({
|
on(RoomsActions.joinRoom, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
error: null
|
error: null
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@@ -184,6 +195,8 @@ export const roomsReducer = createReducer(
|
|||||||
currentRoom: enriched,
|
currentRoom: enriched,
|
||||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
|
isSignalServerReconnecting: false,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
activeChannelId: 'general'
|
activeChannelId: 'general'
|
||||||
};
|
};
|
||||||
@@ -205,6 +218,8 @@ export const roomsReducer = createReducer(
|
|||||||
...state,
|
...state,
|
||||||
currentRoom: null,
|
currentRoom: null,
|
||||||
roomSettings: null,
|
roomSettings: null,
|
||||||
|
isSignalServerReconnecting: false,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
isConnected: false
|
isConnected: false
|
||||||
})),
|
})),
|
||||||
@@ -213,6 +228,7 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.viewServer, (state) => ({
|
on(RoomsActions.viewServer, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
error: null
|
error: null
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@@ -224,6 +240,7 @@ export const roomsReducer = createReducer(
|
|||||||
currentRoom: enriched,
|
currentRoom: enriched,
|
||||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
activeChannelId: 'general'
|
activeChannelId: 'general'
|
||||||
};
|
};
|
||||||
@@ -252,7 +269,13 @@ export const roomsReducer = createReducer(
|
|||||||
description: settings.description,
|
description: settings.description,
|
||||||
topic: settings.topic,
|
topic: settings.topic,
|
||||||
isPrivate: settings.isPrivate,
|
isPrivate: settings.isPrivate,
|
||||||
password: settings.password,
|
password: settings.password === '' ? undefined : (settings.password ?? baseRoom.password),
|
||||||
|
hasPassword:
|
||||||
|
typeof settings.hasPassword === 'boolean'
|
||||||
|
? settings.hasPassword
|
||||||
|
: (typeof settings.password === 'string'
|
||||||
|
? settings.password.trim().length > 0
|
||||||
|
: baseRoom.hasPassword),
|
||||||
maxUsers: settings.maxUsers
|
maxUsers: settings.maxUsers
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,6 +295,8 @@ export const roomsReducer = createReducer(
|
|||||||
// Delete room
|
// Delete room
|
||||||
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
|
||||||
|
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
|
||||||
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
||||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
|
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
|
||||||
})),
|
})),
|
||||||
@@ -279,6 +304,8 @@ export const roomsReducer = createReducer(
|
|||||||
// Forget room (local only)
|
// Forget room (local only)
|
||||||
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
|
||||||
|
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
|
||||||
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
||||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
|
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
|
||||||
})),
|
})),
|
||||||
@@ -288,6 +315,8 @@ export const roomsReducer = createReducer(
|
|||||||
...state,
|
...state,
|
||||||
currentRoom: enrichRoom(room),
|
currentRoom: enrichRoom(room),
|
||||||
savedRooms: upsertRoom(state.savedRooms, room),
|
savedRooms: upsertRoom(state.savedRooms, room),
|
||||||
|
isSignalServerReconnecting: false,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
isConnected: true
|
isConnected: true
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@@ -296,6 +325,8 @@ export const roomsReducer = createReducer(
|
|||||||
...state,
|
...state,
|
||||||
currentRoom: null,
|
currentRoom: null,
|
||||||
roomSettings: null,
|
roomSettings: null,
|
||||||
|
isSignalServerReconnecting: false,
|
||||||
|
signalServerCompatibilityError: null,
|
||||||
isConnected: false
|
isConnected: false
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@@ -360,6 +391,16 @@ export const roomsReducer = createReducer(
|
|||||||
isConnecting
|
isConnecting
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
on(RoomsActions.setSignalServerReconnecting, (state, { isReconnecting }) => ({
|
||||||
|
...state,
|
||||||
|
isSignalServerReconnecting: isReconnecting
|
||||||
|
})),
|
||||||
|
|
||||||
|
on(RoomsActions.setSignalServerCompatibilityError, (state, { message }) => ({
|
||||||
|
...state,
|
||||||
|
signalServerCompatibilityError: message
|
||||||
|
})),
|
||||||
|
|
||||||
// Channel management
|
// Channel management
|
||||||
on(RoomsActions.selectChannel, (state, { channelId }) => ({
|
on(RoomsActions.selectChannel, (state, { channelId }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export const selectIsConnected = createSelector(
|
|||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.isConnected
|
(state) => state.isConnected
|
||||||
);
|
);
|
||||||
|
export const selectIsSignalServerReconnecting = createSelector(
|
||||||
|
selectRoomsState,
|
||||||
|
(state) => state.isSignalServerReconnecting
|
||||||
|
);
|
||||||
|
export const selectSignalServerCompatibilityError = createSelector(
|
||||||
|
selectRoomsState,
|
||||||
|
(state) => state.signalServerCompatibilityError
|
||||||
|
);
|
||||||
export const selectRoomsError = createSelector(
|
export const selectRoomsError = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.error
|
(state) => state.error
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from './users.selectors';
|
} from './users.selectors';
|
||||||
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import {
|
import {
|
||||||
BanEntry,
|
BanEntry,
|
||||||
@@ -56,6 +57,7 @@ export class UsersEffects {
|
|||||||
private actions$ = inject(Actions);
|
private actions$ = inject(Actions);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
// Load current user from storage
|
// Load current user from storage
|
||||||
@@ -162,6 +164,20 @@ export class UsersEffects {
|
|||||||
|
|
||||||
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
||||||
|
|
||||||
|
return this.serverDirectory.kickServerMember(
|
||||||
|
room.id,
|
||||||
|
{
|
||||||
|
actorUserId: currentUser.id,
|
||||||
|
actorRole: currentUser.role,
|
||||||
|
targetUserId: userId
|
||||||
|
},
|
||||||
|
this.toSourceSelector(room)
|
||||||
|
).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to revoke server membership on kick:', error);
|
||||||
|
return of(void 0);
|
||||||
|
}),
|
||||||
|
mergeMap(() => {
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'kick',
|
type: 'kick',
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
@@ -181,6 +197,8 @@ export class UsersEffects {
|
|||||||
changes: { members: nextMembers } })
|
changes: { members: nextMembers } })
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,7 +246,25 @@ export class UsersEffects {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
return from(this.db.saveBan(ban)).pipe(
|
return this.serverDirectory.banServerMember(
|
||||||
|
room.id,
|
||||||
|
{
|
||||||
|
actorUserId: currentUser.id,
|
||||||
|
actorRole: currentUser.role,
|
||||||
|
targetUserId: userId,
|
||||||
|
banId: ban.oderId,
|
||||||
|
displayName: ban.displayName,
|
||||||
|
reason,
|
||||||
|
expiresAt
|
||||||
|
},
|
||||||
|
this.toSourceSelector(room)
|
||||||
|
).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to persist server ban:', error);
|
||||||
|
return of(void 0);
|
||||||
|
}),
|
||||||
|
switchMap(() =>
|
||||||
|
from(this.db.saveBan(ban)).pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'ban',
|
type: 'ban',
|
||||||
@@ -254,6 +290,8 @@ export class UsersEffects {
|
|||||||
return actions;
|
return actions;
|
||||||
}),
|
}),
|
||||||
catchError(() => EMPTY)
|
catchError(() => EMPTY)
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -279,7 +317,21 @@ export class UsersEffects {
|
|||||||
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
|
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
return from(this.db.removeBan(oderId)).pipe(
|
return this.serverDirectory.unbanServerMember(
|
||||||
|
room.id,
|
||||||
|
{
|
||||||
|
actorUserId: currentUser.id,
|
||||||
|
actorRole: currentUser.role,
|
||||||
|
banId: oderId
|
||||||
|
},
|
||||||
|
this.toSourceSelector(room)
|
||||||
|
).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to remove server ban:', error);
|
||||||
|
return of(void 0);
|
||||||
|
}),
|
||||||
|
switchMap(() =>
|
||||||
|
from(this.db.removeBan(oderId)).pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'unban',
|
type: 'unban',
|
||||||
@@ -289,6 +341,8 @@ export class UsersEffects {
|
|||||||
}),
|
}),
|
||||||
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
||||||
catchError(() => EMPTY)
|
catchError(() => EMPTY)
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -394,6 +448,13 @@ export class UsersEffects {
|
|||||||
return savedRooms.find((room) => room.id === roomId) ?? null;
|
return savedRooms.find((room) => room.id === roomId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
|
||||||
|
return {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||||
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
|
defaultServers: [
|
||||||
|
{
|
||||||
|
key: 'toju-primary',
|
||||||
|
name: 'Toju Signal',
|
||||||
|
url: 'https://signal.toju.app'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'toju-sweden',
|
||||||
|
name: 'Toju Signal Sweden',
|
||||||
|
url: 'https://signal-sweden.toju.app'
|
||||||
|
}
|
||||||
|
],
|
||||||
defaultServerUrl: 'https://signal.toju.app'
|
defaultServerUrl: 'https://signal.toju.app'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
|
defaultServers: [
|
||||||
|
{
|
||||||
|
key: 'default',
|
||||||
|
name: 'Default Server',
|
||||||
|
url: 'https://46.59.68.77:3001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'toju-primary',
|
||||||
|
name: 'Toju Signal',
|
||||||
|
url: 'https://signal.toju.app'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'toju-sweden',
|
||||||
|
name: 'Toju Signal Sweden',
|
||||||
|
url: 'https://signal-sweden.toju.app'
|
||||||
|
}
|
||||||
|
],
|
||||||
defaultServerUrl: 'https://46.59.68.77:3001'
|
defaultServerUrl: 'https://46.59.68.77:3001'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const DEV_SINGLE_INSTANCE_EXIT_CODE = 23;
|
||||||
|
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||||
|
|
||||||
function isWaylandSession(env) {
|
function isWaylandSession(env) {
|
||||||
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
|
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
|
||||||
|
|
||||||
@@ -44,11 +47,40 @@ function buildElectronArgs(argv) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDevelopmentLaunch(env) {
|
||||||
|
return String(env.NODE_ENV || '').trim().toLowerCase() === 'development';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChildEnv(env) {
|
||||||
|
const nextEnv = { ...env };
|
||||||
|
|
||||||
|
if (isDevelopmentLaunch(env)) {
|
||||||
|
nextEnv[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV] = String(DEV_SINGLE_INSTANCE_EXIT_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepProcessAliveForExistingInstance() {
|
||||||
|
console.log(
|
||||||
|
'Electron is already running; keeping the dev services alive and routing links to the open window.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {}, 60_000);
|
||||||
|
const shutdown = () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.once('SIGINT', shutdown);
|
||||||
|
process.once('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const electronBinary = resolveElectronBinary();
|
const electronBinary = resolveElectronBinary();
|
||||||
const args = buildElectronArgs(process.argv.slice(2));
|
const args = buildElectronArgs(process.argv.slice(2));
|
||||||
const child = spawn(electronBinary, args, {
|
const child = spawn(electronBinary, args, {
|
||||||
env: process.env,
|
env: buildChildEnv(process.env),
|
||||||
stdio: 'inherit'
|
stdio: 'inherit'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +90,11 @@ function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
|
if (code === DEV_SINGLE_INSTANCE_EXIT_CODE) {
|
||||||
|
keepProcessAliveForExistingInstance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
process.kill(process.pid, signal);
|
process.kill(process.pid, signal);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user