6 Commits

Author SHA1 Message Date
Myx
c3ef8e8800 Allow multiple signal servers (might need rollback)
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 9m58s
Queue Release Build / build-linux (push) Successful in 26m26s
Queue Release Build / build-windows (push) Successful in 25m3s
Queue Release Build / finalize (push) Successful in 1m43s
2026-03-19 02:11:15 +01:00
Myx
c862c2fe03 Auto start with system
Some checks failed
Queue Release Build / prepare (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
Deploy Web Apps / deploy (push) Successful in 6m2s
2026-03-18 23:46:16 +01:00
Myx
4faa62864d Fix syncing issues
Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
2026-03-18 23:11:48 +01:00
Myx
1cdd1c5d2b fix typing indicator on wrong server
Some checks failed
Queue Release Build / build-linux (push) Blocked by required conditions
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
2026-03-18 22:10:11 +01:00
Myx
141de64767 Reconnection when signal server is not active and minor changes 2026-03-18 20:45:31 +01:00
Myx
eb987ac672 Private servers with password and invite links (Experimental) 2026-03-18 20:42:40 +01:00
83 changed files with 5184 additions and 725 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
registerAppLifecycle();
if (initializeDeepLinkHandling()) {
registerAppLifecycle();
}

View File

@@ -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"`);
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

@@ -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.');
} }
}; };

View 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"`);
}
}

View File

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

View File

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

View 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)}`;
}

View 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()
}));
});

View File

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

View 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 });
}
}

View File

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

View File

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

View File

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

View File

@@ -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: () =>

View File

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

View File

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

View File

@@ -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);
} }
} }
} }
@@ -375,9 +447,9 @@ export class AttachmentService {
* message to all connected peers. * message to all connected peers.
* *
* 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 {

View File

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

View File

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

View File

@@ -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);
}
if (message.oderId) {
this.peerSignalingUrlMap.set(message.oderId, signalUrl);
}
if (message.oderId && message.serverId) {
this.trackPeerInServer(message.oderId, message.serverId);
}
} }
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void { 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) {
this.peerManager.removePeer(message.oderId); const hasRemainingSharedServers = Array.isArray(message.serverIds)
this.peerServerMap.delete(message.oderId); ? this.replacePeerSharedServers(message.oderId, message.serverIds)
: (message.serverId
? this.untrackPeerFromServer(message.oderId, message.serverId)
: false);
if (!hasRemainingSharedServers) {
this.peerManager.removePeer(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();

View File

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

View File

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

View File

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

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

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

View File

@@ -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 (optional)</label
>Password</label >
> <input
<input type="password"
type="password" [(ngModel)]="newServerPassword"
[(ngModel)]="newServerPassword" placeholder="Leave blank to allow joining without a password"
placeholder="Enter 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"
> >

View File

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

View File

@@ -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()!"

View File

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

View File

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

View File

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

View File

@@ -9,21 +9,35 @@
/> />
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4> <h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
</div> </div>
<button <div class="flex items-center gap-2">
(click)="testAllServers()" @if (hasMissingDefaultServers()) {
[disabled]="isTesting()" <button
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" type="button"
> (click)="restoreDefaultServers()"
<ng-icon class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
name="lucideRefreshCw" >
class="w-3.5 h-3.5" Restore Defaults
[class.animate-spin]="isTesting()" </button>
/> }
Test All <button
</button> type="button"
(click)="testAllServers()"
[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"
>
<ng-icon
name="lucideRefreshCw"
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All
</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"

View File

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

View File

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

View File

@@ -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;
const normalizedPassword = this.roomPassword.trim();
const settings: {
description: string;
hasPassword?: boolean;
isPrivate: boolean;
maxUsers: number;
name: string;
password?: string;
} = {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
};
if (this.passwordAction() === 'remove') {
settings.password = '';
settings.hasPassword = false;
} else if (normalizedPassword) {
settings.password = normalizedPassword;
settings.hasPassword = true;
}
this.store.dispatch( this.store.dispatch(
RoomsActions.updateRoomSettings({ RoomsActions.updateRoomSettings({
roomId: room.id, roomId: room.id,
settings: { settings
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
}
}) })
); );
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);
} }

View File

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

View File

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

View File

@@ -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,23 +28,34 @@
/> />
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2> <h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
</div> </div>
<button <div class="flex items-center gap-2">
(click)="testAllServers()" @if (hasMissingDefaultServers()) {
[disabled]="isTesting()" <button
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" type="button"
> (click)="restoreDefaultServers()"
<ng-icon class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
name="lucideRefreshCw" >
class="w-4 h-4" Restore Defaults
[class.animate-spin]="isTesting()" </button>
/> }
Test All <button
</button> type="button"
(click)="testAllServers()"
[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"
>
<ng-icon
name="lucideRefreshCw"
class="w-4 h-4"
[class.animate-spin]="isTesting()"
/>
Test All
</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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' {{
? 'No displays were reported by Electron right now.' activeTab() === 'screen'
: 'Restore the window you want to share and try again.' }} ? 'No displays were reported by Electron right now.'
: 'Restore the window you want to share and try again.'
}}
</p> </p>
</div> </div>
</div> </div>

View File

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

View File

@@ -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,63 +152,83 @@ 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(() =>
timer(SYNC_POLL_FAST_MS).pipe( this.syncReset$.pipe(
repeat({ startWith(undefined),
delay: () => switchMap(() =>
timer( timer(SYNC_POLL_FAST_MS).pipe(
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS repeat({
) delay: () =>
}), timer(
takeUntil(this.syncReset$), this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
withLatestFrom(this.store.select(selectCurrentRoom)), )
filter( }),
([, room]) => withLatestFrom(this.store.select(selectCurrentRoom)),
!!room && this.webrtc.getConnectedPeers().length > 0 filter(
), ([, room]) =>
exhaustMap(([, room]) => { !!room && this.webrtc.getConnectedPeers().length > 0
const peers = this.webrtc.getConnectedPeers(); ),
exhaustMap(([, room]) => {
const peers = this.webrtc.getConnectedPeers();
if (!room || peers.length === 0) { if (!room || peers.length === 0) {
return of(MessagesActions.syncComplete()); return of(MessagesActions.syncComplete());
}
return from(
this.db.getMessages(room.id, INVENTORY_LIMIT, 0)
).pipe(
map(() => {
for (const pid of peers) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: room.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error,
peerId: pid,
roomId: room.id
});
}
} }
return MessagesActions.startSync(); return from(
}), this.db.getMessages(room.id, INVENTORY_LIMIT, 0)
catchError((error) => { ).pipe(
this.lastSyncClean = false; map(() => {
this.debugging.warn('messages', 'Periodic sync poll failed', { for (const pid of peers) {
error, try {
roomId: room.id this.webrtc.sendToPeer(pid, {
}); type: 'chat-inventory-request',
roomId: room.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error,
peerId: pid,
roomId: room.id
});
}
}
return of(MessagesActions.syncComplete()); return MessagesActions.startSync();
}),
catchError((error) => {
this.lastSyncClean = false;
this.debugging.warn('messages', 'Periodic sync poll failed', {
error,
roomId: room.id
});
return of(MessagesActions.syncComplete());
})
);
}) })
); )
}) )
) )
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,24 +164,40 @@ export class UsersEffects {
const nextMembers = removeRoomMember(room.members ?? [], userId, userId); const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
this.webrtc.broadcastMessage({ return this.serverDirectory.kickServerMember(
type: 'kick', room.id,
targetUserId: userId, {
roomId: room.id, actorUserId: currentUser.id,
kickedBy: 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({
type: 'kick',
targetUserId: userId,
roomId: room.id,
kickedBy: currentUser.id
});
return currentRoom?.id === room.id return currentRoom?.id === room.id
? [ ? [
RoomsActions.updateRoom({ roomId: room.id, RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }), changes: { members: nextMembers } }),
UsersActions.kickUserSuccess({ userId, UsersActions.kickUserSuccess({ userId,
roomId: room.id }) roomId: room.id })
] ]
: of( : of(
RoomsActions.updateRoom({ roomId: room.id, RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }) changes: { members: nextMembers } })
); );
})
);
}) })
) )
); );
@@ -228,32 +246,52 @@ export class UsersEffects {
timestamp: Date.now() timestamp: Date.now()
}; };
return from(this.db.saveBan(ban)).pipe( return this.serverDirectory.banServerMember(
tap(() => { room.id,
this.webrtc.broadcastMessage({ {
type: 'ban', actorUserId: currentUser.id,
targetUserId: userId, actorRole: currentUser.role,
roomId: room.id, targetUserId: userId,
bannedBy: currentUser.id, banId: ban.oderId,
ban displayName: ban.displayName,
}); reason,
expiresAt
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to persist server ban:', error);
return of(void 0);
}), }),
mergeMap(() => { switchMap(() =>
const actions: (ReturnType<typeof RoomsActions.updateRoom> from(this.db.saveBan(ban)).pipe(
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [ tap(() => {
RoomsActions.updateRoom({ roomId: room.id, this.webrtc.broadcastMessage({
changes: { members: nextMembers } }) type: 'ban',
]; targetUserId: userId,
roomId: room.id,
bannedBy: currentUser.id,
ban
});
}),
mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
];
if (currentRoom?.id === room.id) { if (currentRoom?.id === room.id) {
actions.push(UsersActions.banUserSuccess({ userId, actions.push(UsersActions.banUserSuccess({ userId,
roomId: room.id, roomId: room.id,
ban })); ban }));
} }
return actions; return actions;
}), }),
catchError(() => EMPTY) catchError(() => EMPTY)
)
)
); );
}) })
) )
@@ -279,16 +317,32 @@ 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(
tap(() => { room.id,
this.webrtc.broadcastMessage({ {
type: 'unban', actorUserId: currentUser.id,
roomId: room.id, actorRole: currentUser.role,
banOderId: oderId banId: oderId
}); },
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to remove server ban:', error);
return of(void 0);
}), }),
map(() => UsersActions.unbanUserSuccess({ oderId })), switchMap(() =>
catchError(() => EMPTY) from(this.db.removeBan(oderId)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'unban',
roomId: room.id,
banOderId: oderId
});
}),
map(() => UsersActions.unbanUserSuccess({ oderId })),
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);

View File

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

View File

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

View File

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