6 Commits

Author SHA1 Message Date
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
Myx
f8fd78d21a Add server variables
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 15m14s
Queue Release Build / build-linux (push) Successful in 22m12s
Queue Release Build / build-windows (push) Successful in 23m20s
Queue Release Build / finalize (push) Successful in 2m12s
2026-03-15 16:12:21 +01:00
79 changed files with 4076 additions and 499 deletions

View File

@@ -1,4 +1,5 @@
# Toggle SSL for local development (true/false) # Toggle SSL for local development (true/false)
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https:// # When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
# When false: plain HTTP everywhere (only works on localhost) # When false: plain HTTP everywhere (only works on localhost)
# Overrides server/data/variables.json for local development only
SSL=true SSL=true

View File

@@ -17,7 +17,7 @@ Desktop chat app with three parts:
Root `.env`: Root `.env`:
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode - `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
- `PORT=3001` changes the server port - `PORT=3001` changes the server port in local development and overrides the server app setting
If `SSL=true`, run `./generate-cert.sh` once. If `SSL=true`, run `./generate-cert.sh` once.
@@ -25,6 +25,10 @@ Server files:
- `server/data/variables.json` holds `klipyApiKey` - `server/data/variables.json` holds `klipyApiKey`
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates - `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
## Main commands ## Main commands

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

@@ -2,13 +2,20 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { resolveRuntimePath } from '../runtime-paths'; import { resolveRuntimePath } from '../runtime-paths';
export type ServerHttpProtocol = 'http' | 'https';
export interface ServerVariablesConfig { export interface ServerVariablesConfig {
klipyApiKey: string; klipyApiKey: string;
releaseManifestUrl: string; releaseManifestUrl: string;
serverPort: number;
serverProtocol: ServerHttpProtocol;
serverHost: string;
} }
const DATA_DIR = resolveRuntimePath('data'); const DATA_DIR = resolveRuntimePath('data');
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json'); const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
const DEFAULT_SERVER_PORT = 3001;
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
function normalizeKlipyApiKey(value: unknown): string { function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function normalizeServerHost(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeServerProtocol(
value: unknown,
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
): ServerHttpProtocol {
if (typeof value === 'boolean') {
return value ? 'https' : 'http';
}
if (typeof value !== 'string') {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'https' || normalized === 'true') {
return 'https';
}
if (normalized === 'http' || normalized === 'false') {
return 'http';
}
return fallback;
}
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
const parsed = typeof value === 'number'
? value
: typeof value === 'string'
? Number.parseInt(value.trim(), 10)
: Number.NaN;
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
? parsed
: fallback;
}
function hasEnvironmentOverride(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } { function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
if (!fs.existsSync(VARIABLES_FILE)) { if (!fs.existsSync(VARIABLES_FILE)) {
return { rawContents: '', parsed: {} }; return { rawContents: '', parsed: {} };
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
} }
const { rawContents, parsed } = readRawVariables(); const { rawContents, parsed } = readRawVariables();
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
const normalized = { const normalized = {
...parsed, ...remainingParsed,
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey), klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl) releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
}; };
const nextContents = JSON.stringify(normalized, null, 2) + '\n'; const nextContents = JSON.stringify(normalized, null, 2) + '\n';
@@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return { return {
klipyApiKey: normalized.klipyApiKey, klipyApiKey: normalized.klipyApiKey,
releaseManifestUrl: normalized.releaseManifestUrl releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol,
serverHost: normalized.serverHost
}; };
} }
@@ -84,3 +143,29 @@ export function hasKlipyApiKey(): boolean {
export function getReleaseManifestUrl(): string { export function getReleaseManifestUrl(): string {
return getVariablesConfig().releaseManifestUrl; return getVariablesConfig().releaseManifestUrl;
} }
export function getServerProtocol(): ServerHttpProtocol {
if (hasEnvironmentOverride(process.env.SSL)) {
return normalizeServerProtocol(process.env.SSL);
}
return getVariablesConfig().serverProtocol;
}
export function getServerPort(): number {
if (hasEnvironmentOverride(process.env.PORT)) {
return normalizeServerPort(process.env.PORT);
}
return getVariablesConfig().serverPort;
}
export function getServerHost(): string | undefined {
const serverHost = getVariablesConfig().serverHost;
return serverHost || undefined;
}
export function isHttpsServerEnabled(): boolean {
return getServerProtocol() === 'https';
}

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

@@ -14,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app'; import { createApp } from './app';
import { import {
ensureVariablesConfig, ensureVariablesConfig,
getServerHost,
getVariablesConfigPath, getVariablesConfigPath,
hasKlipyApiKey getServerPort,
getServerProtocol,
ServerHttpProtocol
} from './config/variables'; } from './config/variables';
import { setupWebSocket } from './websocket'; import { setupWebSocket } from './websocket';
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true'; function formatHostForUrl(host: string): string {
const PORT = process.env.PORT || 3001; if (host.startsWith('[') || !host.includes(':')) {
return host;
}
function buildServer(app: ReturnType<typeof createApp>) { return `[${host}]`;
if (USE_SSL) { }
function getDisplayHost(serverHost: string | undefined): string {
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
return 'localhost';
}
return serverHost;
}
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
if (serverProtocol === 'https') {
const certDir = resolveCertificateDirectory(); const certDir = resolveCertificateDirectory();
const certFile = path.join(certDir, 'localhost.crt'); const certFile = path.join(certDir, 'localhost.crt');
const keyFile = path.join(certDir, 'localhost.key'); const keyFile = path.join(certDir, 'localhost.key');
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) { if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
console.error(`SSL=true but certs not found in ${certDir}`); console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
console.error('Run ./generate-cert.sh first.'); console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
process.exit(1); process.exit(1);
} }
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
} }
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
ensureVariablesConfig(); const variablesConfig = ensureVariablesConfig();
const serverProtocol = getServerProtocol();
const serverPort = getServerPort();
const serverHost = getServerHost();
const bindHostLabel = serverHost || 'default interface';
console.log('[Config] Variables loaded from:', getVariablesConfigPath()); console.log('[Config] Variables loaded from:', getVariablesConfigPath());
if (!hasKlipyApiKey()) { if (
variablesConfig.serverProtocol !== serverProtocol
|| variablesConfig.serverPort !== serverPort
) {
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
} else {
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
}
if (!variablesConfig.klipyApiKey) {
console.log('[KLIPY] API key not configured. GIF search is disabled.'); console.log('[KLIPY] API key not configured. GIF search is disabled.');
} }
await initDatabase(); await initDatabase();
const app = createApp(); const app = createApp();
const server = buildServer(app); const server = buildServer(app, serverProtocol);
setupWebSocket(server); setupWebSocket(server);
@@ -64,14 +94,29 @@ async function bootstrap(): Promise<void> {
.catch(err => console.error('Failed to clean up stale join requests:', err)); .catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000); }, 60 * 1000);
server.listen(PORT, () => { const onListening = () => {
const proto = USE_SSL ? 'https' : 'http'; const displayHost = formatHostForUrl(getDisplayHost(serverHost));
const wsProto = USE_SSL ? 'wss' : 'ws'; const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
const localHostNames = [
'localhost',
'127.0.0.1',
'::1'
];
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`); console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
console.log(` REST API: ${proto}://localhost:${PORT}/api`); console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`); console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
});
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
}
};
if (serverHost) {
server.listen(serverPort, serverHost, onListening);
} else {
server.listen(serverPort, onListening);
}
} }
bootstrap().catch((err) => { bootstrap().catch((err) => {

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

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

View File

@@ -12,11 +12,7 @@ import {
forkJoin forkJoin
} from 'rxjs'; } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { import { ServerInfo, User } from '../models/index';
ServerInfo,
JoinRequest,
User
} from '../models/index';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -40,6 +36,69 @@ export interface ServerEndpoint {
latency?: number; latency?: number;
} }
export interface ServerSourceSelector {
sourceId?: string;
sourceUrl?: string;
}
export interface ServerJoinAccessRequest {
roomId: string;
userId: string;
userPublicKey: string;
displayName: string;
password?: string;
inviteId?: string;
}
export interface ServerJoinAccessResponse {
success: boolean;
signalingUrl: string;
joinedBefore: boolean;
via: 'membership' | 'password' | 'invite' | 'public';
server: ServerInfo;
}
export interface CreateServerInviteRequest {
requesterUserId: string;
requesterDisplayName?: string;
requesterRole?: string;
}
export interface ServerInviteInfo {
id: string;
serverId: string;
createdAt: number;
expiresAt: number;
inviteUrl: string;
browserUrl: string;
appUrl: string;
sourceUrl: string;
createdBy?: string;
createdByDisplayName?: string;
isExpired: boolean;
server: ServerInfo;
}
export interface KickServerMemberRequest {
actorUserId: string;
actorRole?: string;
targetUserId: string;
}
export interface BanServerMemberRequest extends KickServerMemberRequest {
banId?: string;
displayName?: string;
reason?: string;
expiresAt?: number;
}
export interface UnbanServerMemberRequest {
actorUserId: string;
actorRole?: string;
banId?: string;
targetUserId?: string;
}
/** localStorage key that persists the user's configured endpoints. */ /** localStorage key that persists the user's configured endpoints. */
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
/** Timeout (ms) for server health-check and alternative-endpoint pings. */ /** Timeout (ms) for server health-check and alternative-endpoint pings. */
@@ -131,7 +190,7 @@ export class ServerDirectoryService {
* *
* @param server - Name and URL of the endpoint to add. * @param server - Name and URL of the endpoint to add.
*/ */
addServer(server: { name: string; url: string }): void { addServer(server: { name: string; url: string }): ServerEndpoint {
const sanitisedUrl = this.sanitiseUrl(server.url); const sanitisedUrl = this.sanitiseUrl(server.url);
const newEndpoint: ServerEndpoint = { const newEndpoint: ServerEndpoint = {
id: uuidv4(), id: uuidv4(),
@@ -144,6 +203,40 @@ export class ServerDirectoryService {
this._servers.update((endpoints) => [...endpoints, newEndpoint]); this._servers.update((endpoints) => [...endpoints, newEndpoint]);
this.saveEndpoints(); this.saveEndpoints();
return newEndpoint;
}
/** Ensure an endpoint exists for a given URL, optionally activating it. */
ensureServerEndpoint(
server: { name: string; url: string },
options?: { setActive?: boolean }
): ServerEndpoint {
const sanitisedUrl = this.sanitiseUrl(server.url);
const existing = this.findServerByUrl(sanitisedUrl);
if (existing) {
if (options?.setActive) {
this.setActiveServer(existing.id);
}
return existing;
}
const created = this.addServer({ name: server.name,
url: sanitisedUrl });
if (options?.setActive) {
this.setActiveServer(created.id);
}
return created;
}
/** Find a configured endpoint by URL. */
findServerByUrl(url: string): ServerEndpoint | undefined {
const sanitisedUrl = this.sanitiseUrl(url);
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
} }
/** /**
@@ -265,18 +358,13 @@ export class ServerDirectoryService {
} }
/** Expose the API base URL for external consumers. */ /** Expose the API base URL for external consumers. */
getApiBaseUrl(): string { getApiBaseUrl(selector?: ServerSourceSelector): string {
return this.buildApiBaseUrl(); return this.buildApiBaseUrl(selector);
} }
/** Get the WebSocket URL derived from the active endpoint. */ /** Get the WebSocket URL derived from the active endpoint. */
getWebSocketUrl(): string { getWebSocketUrl(selector?: ServerSourceSelector): string {
const active = this.activeServer(); return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
if (!active)
return buildDefaultServerUrl().replace(/^http/, 'ws');
return active.url.replace(/^http/, 'ws');
} }
/** /**
@@ -310,11 +398,11 @@ export class ServerDirectoryService {
} }
/** Fetch details for a single server. */ /** Fetch details for a single server. */
getServer(serverId: string): Observable<ServerInfo | null> { getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.http return this.http
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`) .get<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
.pipe( .pipe(
map((server) => this.normalizeServerInfo(server, this.activeServer())), map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => { catchError((error) => {
console.error('Failed to get server:', error); console.error('Failed to get server:', error);
return of(null); return of(null);
@@ -324,10 +412,11 @@ export class ServerDirectoryService {
/** Register a new server listing in the directory. */ /** Register a new server listing in the directory. */
registerServer( registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string } server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> { ): Observable<ServerInfo> {
return this.http return this.http
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server) .post<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers`, server)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to register server:', error); console.error('Failed to register server:', error);
@@ -339,10 +428,15 @@ export class ServerDirectoryService {
/** Update an existing server listing. */ /** Update an existing server listing. */
updateServer( updateServer(
serverId: string, serverId: string,
updates: Partial<ServerInfo> & { currentOwnerId: string } updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> { ): Observable<ServerInfo> {
return this.http return this.http
.put<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates) .put<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`, updates)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to update server:', error); console.error('Failed to update server:', error);
@@ -352,9 +446,9 @@ export class ServerDirectoryService {
} }
/** Remove a server listing from the directory. */ /** Remove a server listing from the directory. */
unregisterServer(serverId: string): Observable<void> { unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http return this.http
.delete<void>(`${this.buildApiBaseUrl()}/servers/${serverId}`) .delete<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to unregister server:', error); console.error('Failed to unregister server:', error);
@@ -364,9 +458,9 @@ export class ServerDirectoryService {
} }
/** Retrieve users currently connected to a server. */ /** Retrieve users currently connected to a server. */
getServerUsers(serverId: string): Observable<User[]> { getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.http return this.http
.get<User[]>(`${this.buildApiBaseUrl()}/servers/${serverId}/users`) .get<User[]>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/users`)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to get server users:', error); console.error('Failed to get server users:', error);
@@ -377,11 +471,12 @@ export class ServerDirectoryService {
/** Send a join request for a server and receive the signaling URL. */ /** Send a join request for a server and receive the signaling URL. */
requestJoin( requestJoin(
request: JoinRequest request: ServerJoinAccessRequest,
): Observable<{ success: boolean; signalingUrl?: string }> { selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.http return this.http
.post<{ success: boolean; signalingUrl?: string }>( .post<ServerJoinAccessResponse>(
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`, `${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`,
request request
) )
.pipe( .pipe(
@@ -392,10 +487,86 @@ export class ServerDirectoryService {
); );
} }
/** Notify the directory that a user has left a server. */ /** Create an expiring invite link for a server. */
notifyLeave(serverId: string, userId: string): Observable<void> { createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.http return this.http
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/leave`, { userId }) .post<ServerInviteInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
.pipe(
catchError((error) => {
console.error('Failed to create invite:', error);
return throwError(() => error);
})
);
}
/** Retrieve public invite metadata. */
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.http
.get<ServerInviteInfo>(`${this.buildApiBaseUrl(selector)}/invites/${inviteId}`)
.pipe(
catchError((error) => {
console.error('Failed to get invite:', error);
return throwError(() => error);
})
);
}
/** Remove a member's stored join access for a server. */
kickServerMember(
serverId: string,
request: KickServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
.pipe(
catchError((error) => {
console.error('Failed to kick server member:', error);
return throwError(() => error);
})
);
}
/** Ban a member from a server invite/password access list. */
banServerMember(
serverId: string,
request: BanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
.pipe(
catchError((error) => {
console.error('Failed to ban server member:', error);
return throwError(() => error);
})
);
}
/** Remove a stored server ban. */
unbanServerMember(
serverId: string,
request: UnbanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
.pipe(
catchError((error) => {
console.error('Failed to unban server member:', error);
return throwError(() => error);
})
);
}
/** Remove a user's remembered membership after leaving a server. */
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
.pipe( .pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to notify leave:', error); console.error('Failed to notify leave:', error);
@@ -432,17 +603,8 @@ export class ServerDirectoryService {
* Build the active endpoint's API base URL, stripping trailing * Build the active endpoint's API base URL, stripping trailing
* slashes and accidental `/api` suffixes. * slashes and accidental `/api` suffixes.
*/ */
private buildApiBaseUrl(): string { private buildApiBaseUrl(selector?: ServerSourceSelector): string {
const active = this.activeServer(); return `${this.resolveBaseServerUrl(selector)}/api`;
const rawUrl = active ? active.url : buildDefaultServerUrl();
let base = rawUrl.replace(/\/+$/, '');
if (base.toLowerCase().endsWith('/api')) {
base = base.slice(0, -4);
}
return `${base}/api`;
} }
/** Strip trailing slashes and `/api` suffix from a URL. */ /** Strip trailing slashes and `/api` suffix from a URL. */
@@ -456,6 +618,26 @@ export class ServerDirectoryService {
return cleaned; return cleaned;
} }
private resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
if (selector?.sourceId) {
return this._servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
}
if (selector?.sourceUrl) {
return this.findServerByUrl(selector.sourceUrl) ?? null;
}
return this.activeServer() ?? this._servers()[0] ?? null;
}
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
if (selector?.sourceUrl) {
return this.sanitiseUrl(selector.sourceUrl);
}
return this.resolveEndpoint(selector)?.url ?? buildDefaultServerUrl();
}
/** /**
* Handle both `{ servers: [...] }` and direct `ServerInfo[]` * Handle both `{ servers: [...] }` and direct `ServerInfo[]`
* response shapes from the directory API. * response shapes from the directory API.
@@ -560,45 +742,44 @@ export class ServerDirectoryService {
source?: ServerEndpoint | null source?: ServerEndpoint | null
): ServerInfo { ): ServerInfo {
const candidate = server as Record<string, unknown>; const candidate = server as Record<string, unknown>;
const userCount = typeof candidate['userCount'] === 'number' const sourceName = this.getStringValue(candidate['sourceName']);
? candidate['userCount'] const sourceUrl = this.getStringValue(candidate['sourceUrl']);
: (typeof candidate['currentUsers'] === 'number' ? candidate['currentUsers'] : 0);
const maxUsers = typeof candidate['maxUsers'] === 'number' ? candidate['maxUsers'] : 0;
const isPrivate = typeof candidate['isPrivate'] === 'boolean'
? candidate['isPrivate']
: candidate['isPrivate'] === 1;
return { return {
id: typeof candidate['id'] === 'string' ? candidate['id'] : '', id: this.getStringValue(candidate['id']) ?? '',
name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server', name: this.getStringValue(candidate['name']) ?? 'Unnamed server',
description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined, description: this.getStringValue(candidate['description']),
topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined, topic: this.getStringValue(candidate['topic']),
hostName: hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API',
typeof candidate['hostName'] === 'string' ownerId: this.getStringValue(candidate['ownerId']),
? candidate['hostName'] ownerName: this.getStringValue(candidate['ownerName']),
: (typeof candidate['sourceName'] === 'string' ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']),
? candidate['sourceName'] userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])),
: (source?.name ?? 'Unknown API')), maxUsers: this.getNumberValue(candidate['maxUsers']),
ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined, hasPassword: this.getBooleanValue(candidate['hasPassword']),
ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined, isPrivate: this.getBooleanValue(candidate['isPrivate']),
ownerPublicKey:
typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined,
userCount,
maxUsers,
isPrivate,
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [], tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
createdAt: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(), createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
typeof candidate['sourceId'] === 'string' sourceName: sourceName ?? source?.name,
? candidate['sourceId'] sourceUrl: sourceUrl
: source?.id, ? this.sanitiseUrl(sourceUrl)
sourceName: : (source ? this.sanitiseUrl(source.url) : undefined)
typeof candidate['sourceName'] === 'string'
? candidate['sourceName']
: source?.name
}; };
} }
private getBooleanValue(value: unknown): boolean {
return typeof value === 'boolean' ? value : value === 1;
}
private getNumberValue(value: unknown, fallback = 0): number {
return typeof value === 'number' ? value : fallback;
}
private getStringValue(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
/** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */ /** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */
private loadEndpoints(): void { private loadEndpoints(): void {
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);

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

@@ -71,6 +71,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;
@@ -92,8 +93,8 @@ export class WebRTCService implements OnDestroy {
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>();
@@ -275,6 +276,7 @@ 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.screenShareManager.clearScreenShareRequest(peerId); this.screenShareManager.clearScreenShareRequest(peerId);
}); });
@@ -349,6 +351,10 @@ export class WebRTCService implements OnDestroy {
if (!user.oderId) if (!user.oderId)
continue; continue;
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,10 +373,6 @@ 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);
}
} }
} }
@@ -379,6 +381,10 @@ export class WebRTCService implements OnDestroy {
displayName: message.displayName, displayName: message.displayName,
oderId: message.oderId oderId: message.oderId
}); });
if (message.oderId && message.serverId) {
this.trackPeerInServer(message.oderId, message.serverId);
}
} }
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void { private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
@@ -389,8 +395,16 @@ export class WebRTCService implements OnDestroy {
}); });
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);
}
} }
} }
@@ -404,7 +418,7 @@ export class WebRTCService implements OnDestroy {
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);
@@ -441,8 +455,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);
} }
}); });
@@ -479,6 +493,45 @@ export class WebRTCService implements OnDestroy {
return this.signalingManager.connect(serverUrl); return this.signalingManager.connect(serverUrl);
} }
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.memberServerIds.has(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;
}
/** /**
* Ensure the signaling WebSocket is connected, reconnecting if needed. * Ensure the signaling WebSocket is connected, reconnecting if needed.
* *
@@ -521,6 +574,11 @@ export class WebRTCService implements OnDestroy {
return this.activeServerId; return this.activeServerId;
} }
/** The last signaling URL used by the client, if any. */
getCurrentSignalingUrl(): string | null {
return this.signalingManager.getLastUrl();
}
/** /**
* Send an identify message to the signaling server. * Send an identify message to the signaling server.
* *

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
@@ -263,22 +312,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">

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;
@@ -85,6 +88,11 @@ export class ServerSearchComponent implements OnInit {
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);
@@ -135,16 +143,7 @@ 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. */
@@ -176,7 +175,7 @@ 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
}) })
); );
@@ -198,6 +197,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 +238,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;

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,19 @@ 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.setSignalServerReconnecting({ isReconnecting: false }));
this.store.dispatch(RoomsActions.viewServer({ room })); this.store.dispatch(RoomsActions.viewServer({ room }));
} 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 +135,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 +243,105 @@ 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 }));
}
}
}
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

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

@@ -13,6 +13,16 @@
/> />
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span> <span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</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() }}
@@ -55,8 +65,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 +87,11 @@
Leave Server Leave Server
</button> </button>
} }
@if (inviteStatus()) {
<div class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground">
{{ 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,11 @@ 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 } 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 +27,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 +53,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 +82,22 @@ 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);
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 || '');
showRoomReconnectNotice = computed(() =>
this.inRoom() && (
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 +136,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 +219,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

@@ -29,7 +29,7 @@ export const RoomsActions = createActionGroup({
'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 }>(),
@@ -67,6 +67,7 @@ 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 }>()
} }
}); });

View File

@@ -44,6 +44,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 +96,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 +141,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,6 +150,33 @@ 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(
@@ -159,17 +187,23 @@ export class RoomsEffects {
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' })); return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
} }
const activeEndpoint = this.serverDirectory.activeServer();
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: activeEndpoint?.id,
sourceName: activeEndpoint?.name,
sourceUrl: activeEndpoint?.url
}; };
// Save to local DB // Save to local DB
@@ -184,6 +218,8 @@ 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,
@@ -216,8 +252,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 +290,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 +306,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 +314,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 +347,13 @@ 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 this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms);
if (this.webrtc.isConnected()) {
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,8 +365,12 @@ 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 },
user,
savedRooms
]) => {
if (!user) { if (!user) {
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' })); return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
} }
@@ -325,10 +383,7 @@ export class RoomsEffects {
const oderId = user.oderId || this.webrtc.peerId(); const oderId = user.oderId || this.webrtc.peerId();
if (this.webrtc.isConnected()) { this.connectToRoomSignaling(room, user, oderId, savedRooms);
this.webrtc.setCurrentServer(room.id);
this.webrtc.switchServer(room.id, oderId);
}
this.router.navigate(['/room', room.id]); this.router.navigate(['/room', room.id]);
return of(RoomsActions.viewServerSuccess({ room })); return of(RoomsActions.viewServerSuccess({ room }));
@@ -432,8 +487,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 +508,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 +553,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 +565,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 +809,11 @@ export class RoomsEffects {
}) })
); );
return [UsersActions.clearUsers(), ...joinActions]; return [
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
UsersActions.clearUsers(),
...joinActions
];
} }
case 'user_joined': { case 'user_joined': {
@@ -729,6 +829,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 +844,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 +1056,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 +1122,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 +1192,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 +1317,132 @@ export class RoomsEffects {
{ dispatch: false } { dispatch: false }
); );
private connectToRoomSignaling(
room: Room,
user: User | null,
resolvedOderId?: string,
savedRooms: Room[] = []
): void {
const wsUrl = this.serverDirectory.getWebSocketUrl({
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
});
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
const displayName = user?.displayName || 'Anonymous';
const sameSignalServer = currentWsUrl === wsUrl;
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);
for (const backgroundRoom of backgroundRooms) {
if (!this.webrtc.hasJoinedServer(backgroundRoom.id)) {
this.webrtc.joinRoom(backgroundRoom.id, oderId);
}
}
if (this.webrtc.hasJoinedServer(room.id)) {
this.webrtc.switchServer(room.id, oderId);
} else {
this.webrtc.joinRoom(room.id, oderId);
}
};
if (this.webrtc.isConnected() && sameSignalServer) {
joinCurrentEndpointRooms();
return;
}
if (currentWsUrl && currentWsUrl !== wsUrl) {
this.webrtc.disconnectAll();
}
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 currentWsUrl = this.webrtc.getCurrentSignalingUrl();
const targetRoom = (watchedRoomId
? savedRooms.find((room) => room.id === watchedRoomId) ?? null
: null)
?? (currentWsUrl ? this.findRoomBySignalingUrl(savedRooms, currentWsUrl) : null)
?? currentRoom
?? savedRooms[0]
?? null;
if (!targetRoom) {
return;
}
this.connectToRoomSignaling(targetRoom, user, user.oderId || this.webrtc.peerId(), savedRooms);
}
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 findRoomBySignalingUrl(rooms: Room[], wsUrl: string): Room | null {
return this.getRoomsForSignalingUrl(rooms, wsUrl)[0] ?? null;
}
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,8 @@ 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;
/** 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 +100,7 @@ export const initialState: RoomsState = {
isSearching: false, isSearching: false,
isConnecting: false, isConnecting: false,
isConnected: false, isConnected: false,
isSignalServerReconnecting: false,
loading: false, loading: false,
error: null, error: null,
activeChannelId: 'general' activeChannelId: 'general'
@@ -158,6 +162,7 @@ export const roomsReducer = createReducer(
currentRoom: enriched, currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched), savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false, isConnecting: false,
isSignalServerReconnecting: false,
isConnected: true, isConnected: true,
activeChannelId: 'general' activeChannelId: 'general'
}; };
@@ -184,6 +189,7 @@ export const roomsReducer = createReducer(
currentRoom: enriched, currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched), savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false, isConnecting: false,
isSignalServerReconnecting: false,
isConnected: true, isConnected: true,
activeChannelId: 'general' activeChannelId: 'general'
}; };
@@ -205,6 +211,7 @@ export const roomsReducer = createReducer(
...state, ...state,
currentRoom: null, currentRoom: null,
roomSettings: null, roomSettings: null,
isSignalServerReconnecting: false,
isConnecting: false, isConnecting: false,
isConnected: false isConnected: false
})), })),
@@ -252,7 +259,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 +285,7 @@ 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,
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 +293,7 @@ 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,
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 +303,7 @@ export const roomsReducer = createReducer(
...state, ...state,
currentRoom: enrichRoom(room), currentRoom: enrichRoom(room),
savedRooms: upsertRoom(state.savedRooms, room), savedRooms: upsertRoom(state.savedRooms, room),
isSignalServerReconnecting: false,
isConnected: true isConnected: true
})), })),
@@ -296,6 +312,7 @@ export const roomsReducer = createReducer(
...state, ...state,
currentRoom: null, currentRoom: null,
roomSettings: null, roomSettings: null,
isSignalServerReconnecting: false,
isConnected: false isConnected: false
})), })),
@@ -360,6 +377,11 @@ export const roomsReducer = createReducer(
isConnecting isConnecting
})), })),
on(RoomsActions.setSignalServerReconnecting, (state, { isReconnecting }) => ({
...state,
isSignalServerReconnecting: isReconnecting
})),
// Channel management // Channel management
on(RoomsActions.selectChannel, (state, { channelId }) => ({ on(RoomsActions.selectChannel, (state, { channelId }) => ({
...state, ...state,

View File

@@ -26,6 +26,10 @@ export const selectIsConnected = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.isConnected (state) => state.isConnected
); );
export const selectIsSignalServerReconnecting = createSelector(
selectRoomsState,
(state) => state.isSignalServerReconnecting
);
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,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;