7 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
Myx
150c45c31a Use wayland if possible on linux
All checks were successful
Queue Release Build / prepare (push) Successful in 58s
Deploy Web Apps / deploy (push) Successful in 22m58s
Queue Release Build / build-linux (push) Successful in 1h10m11s
Queue Release Build / build-windows (push) Successful in 32m8s
Queue Release Build / finalize (push) Successful in 3m11s
2026-03-13 20:14:19 +01:00
82 changed files with 4252 additions and 512 deletions

View File

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

View File

@@ -17,7 +17,7 @@ Desktop chat app with three parts:
Root `.env`:
- `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.
@@ -25,6 +25,10 @@ Server files:
- `server/data/variables.json` holds `klipyApiKey`
- `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

2
dev.sh
View File

@@ -33,4 +33,4 @@ fi
exec npx concurrently --kill-others \
"cd server && npm run dev" \
"$NG_SERVE" \
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"

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

@@ -45,9 +45,9 @@ function linuxSpecificFlags(): void {
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
// works for screen capture on Wayland compositors
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
// Chromium chooses the Linux Ozone platform before Electron runs this file.
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
// sessions so the browser process selects the correct backend early enough.
}
function networkFlags(): void {

View File

@@ -1,6 +1,7 @@
import { app, BrowserWindow } from 'electron';
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
import { synchronizeAutoStartSetting } from './auto-start';
import {
initializeDatabase,
destroyDatabase,
@@ -24,6 +25,7 @@ export function registerAppLifecycle(): void {
setupCqrsHandlers();
setupWindowControlHandlers();
setupSystemHandlers();
await synchronizeAutoStartSetting();
initializeDesktopUpdater();
await createWindow();

View File

@@ -12,6 +12,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
hasPassword: room.hasPassword ? 1 : 0,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
@@ -20,7 +21,10 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : 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);

View File

@@ -9,6 +9,7 @@ import {
} from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt,
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull,

View File

@@ -57,6 +57,7 @@ export function rowToRoom(row: RoomEntity) {
topic: row.topic ?? undefined,
hostId: row.hostId,
password: row.password ?? undefined,
hasPassword: !!row.hasPassword,
isPrivate: !!row.isPrivate,
createdAt: row.createdAt,
userCount: row.userCount,
@@ -65,7 +66,10 @@ export function rowToRoom(row: RoomEntity) {
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : 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;
hostId: string;
password?: string;
hasPassword?: boolean;
isPrivate?: boolean;
createdAt: number;
userCount?: number;
@@ -93,6 +94,9 @@ export interface RoomPayload {
permissions?: unknown;
channels?: unknown[];
members?: unknown[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface BanPayload {

View File

@@ -6,6 +6,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -19,6 +20,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto',
autoStart: true,
hardwareAcceleration: true,
manifestUrls: [],
preferredVersion: null,
@@ -81,6 +83,9 @@ export function readDesktopSettings(): DesktopSettings {
return {
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
autoStart: typeof parsed.autoStart === 'boolean'
? parsed.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
@@ -102,6 +107,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
};
const nextSettings: DesktopSettings = {
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
autoStart: typeof mergedSettings.autoStart === 'boolean'
? mergedSettings.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,

View File

@@ -24,6 +24,9 @@ export class RoomEntity {
@Column('text', { nullable: true })
password!: string | null;
@Column('integer', { default: 0 })
hasPassword!: number;
@Column('integer', { default: 0 })
isPrivate!: number;
@@ -50,4 +53,13 @@ export class RoomEntity {
@Column('text', { nullable: true })
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,
type DesktopUpdateServerContext
} from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -83,6 +85,57 @@ interface ClipboardFilePayload {
path?: string;
}
function resolveLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
}
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
.trim()
.toLowerCase();
if (ozonePlatform === 'wayland') {
return 'Wayland';
}
if (ozonePlatform === 'x11') {
return 'X11';
}
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
.trim()
.toLowerCase();
if (ozonePlatformHint === 'wayland') {
return 'Wayland';
}
if (ozonePlatformHint === 'x11') {
return 'X11';
}
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
.toLowerCase();
if (sessionType === 'wayland') {
return 'Wayland';
}
if (sessionType === 'x11') {
return 'X11';
}
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
return 'Wayland';
}
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
return 'X11';
}
return 'Unknown (Linux)';
}
function isSupportedClipboardFileFormat(format: string): boolean {
return FILE_CLIPBOARD_FORMATS.some(
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
@@ -194,6 +247,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
}
export function setupSystemHandlers(): void {
ipcMain.on('get-linux-display-server', (event) => {
event.returnValue = resolveLinuxDisplayServer();
});
ipcMain.handle('open-external', async (_event, url: string) => {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
await shell.openExternal(url);
@@ -203,6 +260,8 @@ export function setupSystemHandlers(): void {
return false;
});
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
ipcMain.handle('get-sources', async () => {
try {
const thumbnailSize = { width: 240, height: 150 };
@@ -271,6 +330,7 @@ export function setupSystemHandlers(): void {
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
const snapshot = updateDesktopSettings(patch);
await synchronizeAutoStartSetting(snapshot.autoStart);
await handleDesktopSettingsChanged();
return snapshot;
});

View File

@@ -1,6 +1,10 @@
import 'reflect-metadata';
import { initializeDeepLinkHandling } from './app/deep-links';
import { configureAppFlags } from './app/flags';
import { registerAppLifecycle } from './app/lifecycle';
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_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
@@ -83,7 +84,24 @@ export interface DesktopUpdateState {
targetVersion: string | null;
}
function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
}
try {
const displayServer = ipcRenderer.sendSync('get-linux-display-server');
return typeof displayServer === 'string' && displayServer.trim().length > 0
? displayServer
: 'Unknown (Linux)';
} catch {
return 'Unknown (Linux)';
}
}
export interface ElectronAPI {
linuxDisplayServer: string;
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
@@ -98,8 +116,10 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -113,12 +133,14 @@ export interface ElectronAPI {
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version';
autoStart?: boolean;
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
vaapiVideoEncode?: boolean;
}) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -126,6 +148,7 @@ export interface ElectronAPI {
restartRequired: boolean;
}>;
relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
@@ -139,6 +162,7 @@ export interface ElectronAPI {
}
const electronAPI: ElectronAPI = {
linuxDisplayServer: readLinuxDisplayServer(),
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
@@ -180,6 +204,7 @@ const electronAPI: ElectronAPI = {
};
},
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
@@ -198,6 +223,17 @@ const electronAPI: ElectronAPI = {
},
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
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'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
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/ui-core": "^0.0.1-alpha.380",
"@timephy/rnnoise-wasm": "^1.0.0",
"auto-launch": "^5.0.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cytoscape": "^3.33.1",
@@ -45,11 +46,12 @@
},
"devDependencies": {
"@angular/build": "^21.0.4",
"@angular/cli": "^21.2.1",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5",
"@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0",
@@ -10816,6 +10818,13 @@
"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": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -12875,6 +12884,11 @@
"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": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -12968,6 +12982,22 @@
"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": {
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@@ -22285,9 +22315,7 @@
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -23745,7 +23773,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -29571,6 +29598,15 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
@@ -31161,6 +31197,12 @@
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"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": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@@ -21,10 +21,10 @@
"server:build": "cd server && npm run build",
"server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev",
"electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
"electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
"migration:create": "typeorm migration:create electron/migrations/New",
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
@@ -70,6 +70,7 @@
"@spartan-ng/cli": "^0.0.1-alpha.589",
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
"@timephy/rnnoise-wasm": "^1.0.0",
"auto-launch": "^5.0.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cytoscape": "^3.33.1",
@@ -96,6 +97,7 @@
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5",
"@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0",
@@ -120,6 +122,14 @@
"build": {
"appId": "com.metoyou.app",
"productName": "MetoYou",
"protocols": [
{
"name": "Toju Invite Links",
"schemes": [
"toju"
]
}
],
"directories": {
"output": "dist-electron"
},

Binary file not shown.

View File

@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
export function createApp(): express.Express {
const app = express();
app.set('trust proxy', true);
app.use(cors());
app.use(express.json());

View File

@@ -2,13 +2,20 @@ import fs from 'fs';
import path from 'path';
import { resolveRuntimePath } from '../runtime-paths';
export type ServerHttpProtocol = 'http' | 'https';
export interface ServerVariablesConfig {
klipyApiKey: string;
releaseManifestUrl: string;
serverPort: number;
serverProtocol: ServerHttpProtocol;
serverHost: string;
}
const DATA_DIR = resolveRuntimePath('data');
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 {
return typeof value === 'string' ? value.trim() : '';
@@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string {
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> } {
if (!fs.existsSync(VARIABLES_FILE)) {
return { rawContents: '', parsed: {} };
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
}
const { rawContents, parsed } = readRawVariables();
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
const normalized = {
...parsed,
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
...remainingParsed,
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
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';
@@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return {
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 {
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 { ServerEntity, JoinRequestEntity } from '../../../entities';
import {
ServerEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
} from '../../../entities';
import { DeleteServerCommand } from '../../types';
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
const { serverId } = command.payload;
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);
}

View File

@@ -11,6 +11,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
description: server.description ?? null,
ownerId: server.ownerId,
ownerPublicKey: server.ownerPublicKey,
passwordHash: server.passwordHash ?? null,
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,

View File

@@ -24,6 +24,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
description: row.description ?? undefined,
ownerId: row.ownerId,
ownerPublicKey: row.ownerPublicKey,
hasPassword: !!row.passwordHash,
passwordHash: row.passwordHash ?? undefined,
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,

View File

@@ -34,6 +34,8 @@ export interface ServerPayload {
description?: string;
ownerId: string;
ownerPublicKey: string;
hasPassword?: boolean;
passwordHash?: string | null;
isPrivate: boolean;
maxUsers: number;
currentUsers: number;

View File

@@ -4,7 +4,10 @@ import { DataSource } from 'typeorm';
import {
AuthUserEntity,
ServerEntity,
JoinRequestEntity
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
} from '../entities';
import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
@@ -51,7 +54,10 @@ export async function initDatabase(): Promise<void> {
entities: [
AuthUserEntity,
ServerEntity,
JoinRequestEntity
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
],
migrations: serverMigrations,
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')
ownerPublicKey!: string;
@Column('text', { nullable: true })
passwordHash!: string | null;
@Column('integer', { default: 0 })
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 { ServerEntity } from './ServerEntity';
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 {
ensureVariablesConfig,
getServerHost,
getVariablesConfigPath,
hasKlipyApiKey
getServerPort,
getServerProtocol,
ServerHttpProtocol
} from './config/variables';
import { setupWebSocket } from './websocket';
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
const PORT = process.env.PORT || 3001;
function formatHostForUrl(host: string): string {
if (host.startsWith('[') || !host.includes(':')) {
return host;
}
function buildServer(app: ReturnType<typeof createApp>) {
if (USE_SSL) {
return `[${host}]`;
}
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 certFile = path.join(certDir, 'localhost.crt');
const keyFile = path.join(certDir, 'localhost.key');
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
console.error(`SSL=true but certs not found in ${certDir}`);
console.error('Run ./generate-cert.sh first.');
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
process.exit(1);
}
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
}
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());
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.');
}
await initDatabase();
const app = createApp();
const server = buildServer(app);
const server = buildServer(app, serverProtocol);
setupWebSocket(server);
@@ -64,14 +94,29 @@ async function bootstrap(): Promise<void> {
.catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000);
server.listen(PORT, () => {
const proto = USE_SSL ? 'https' : 'http';
const wsProto = USE_SSL ? 'wss' : 'ws';
const onListening = () => {
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
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(` REST API: ${proto}://localhost:${PORT}/api`);
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
});
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
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) => {

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 { 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 serversRouter from './servers';
import joinRequestsRouter from './join-requests';
import { invitesApiRouter, invitePageRouter } from './invites';
export function registerRoutes(app: Express): void {
app.use('/api', healthRouter);
@@ -12,5 +13,7 @@ export function registerRoutes(app: Express): void {
app.use('/api', proxyRouter);
app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter);
app.use('/api/invites', invitesApiRouter);
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 { ServerPayload, JoinRequestPayload } from '../cqrs/types';
import { ServerPayload } from '../cqrs/types';
import {
getAllPublicServers,
getServerById,
getUserById,
upsertServer,
deleteServer,
createJoinRequest,
getPendingRequestsForServer
} 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();
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 { passwordHash, ...publicServer } = server;
return {
...server,
...publicServer,
hasPassword: server.hasPassword ?? !!passwordHash,
ownerName: owner?.displayName,
sourceUrl,
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) => {
const { q, tags, limit = 20, offset = 0 } = req.query;
@@ -54,17 +115,30 @@ router.get('/', 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)
return res.status(400).json({ error: 'Missing required fields' });
const passwordHash = passwordHashForInput(password);
const server: ServerPayload = {
id: clientId || uuidv4(),
name,
description,
ownerId,
ownerPublicKey,
hasPassword: !!passwordHash,
passwordHash,
isPrivate: isPrivate ?? false,
maxUsers: maxUsers ?? 0,
currentUsers: 0,
@@ -74,25 +148,216 @@ router.post('/', async (req, res) => {
};
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) => {
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 authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
const normalizedRole = normalizeRole(actingRole);
if (!existing)
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' });
}
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);
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) => {
@@ -128,32 +393,6 @@ router.delete('/:id', async (req, res) => {
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) => {
const { id: serverId } = req.params;
const { ownerId } = req.query;
@@ -170,4 +409,15 @@ router.get('/:id/requests', async (req, res) => {
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;

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 { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId } from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service';
interface WsMessage {
[key: string]: unknown;
@@ -23,8 +24,24 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
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']);
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);
user.serverIds.add(sid);
@@ -71,7 +88,8 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName ?? 'Anonymous',
serverId: leaveSid
serverId: leaveSid,
serverIds: Array.from(user.serverIds)
}, 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);
if (!user)
@@ -133,7 +151,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
break;
case 'join_server':
handleJoinServer(user, message, connectionId);
await handleJoinServer(user, message, connectionId);
break;
case 'view_server':

View File

@@ -25,7 +25,8 @@ function removeDeadConnection(connectionId: string): void {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
serverId: sid
serverId: sid,
serverIds: []
}, 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 {
const message = JSON.parse(data.toString());
handleWebSocketMessage(connectionId, message);
await handleWebSocketMessage(connectionId, message);
} catch (err) {
console.error('Invalid WebSocket message:', err);
}

View File

@@ -17,6 +17,11 @@ export const routes: Routes = [
loadComponent: () =>
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',
loadComponent: () =>

View File

@@ -2,6 +2,7 @@
import {
Component,
OnInit,
OnDestroy,
inject,
HostListener
} from '@angular/core';
@@ -35,6 +36,15 @@ import {
STORAGE_KEY_LAST_VISITED_ROUTE
} from './core/constants';
interface DeepLinkElectronApi {
consumePendingDeepLink?: () => Promise<string | null>;
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
}
type DeepLinkWindow = Window & {
electronAPI?: DeepLinkElectronApi;
};
@Component({
selector: 'app-root',
imports: [
@@ -50,7 +60,7 @@ import {
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App implements OnInit {
export class App implements OnInit, OnDestroy {
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);
@@ -63,6 +73,7 @@ export class App implements OnInit {
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService);
private externalLinks = inject(ExternalLinkService);
private deepLinkCleanup: (() => void) | null = null;
@HostListener('document:click', ['$event'])
onGlobalLinkClick(evt: MouseEvent): void {
@@ -80,6 +91,8 @@ export class App implements OnInit {
await this.timeSync.syncWithEndpoint(apiBase);
} catch {}
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
@@ -87,8 +100,12 @@ export class App implements OnInit {
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) {
if (this.router.url !== '/login' && this.router.url !== '/register') {
this.router.navigate(['/login']).catch(() => {});
if (!this.isPublicRoute(this.router.url)) {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
}).catch(() => {});
}
} else {
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 {
this.settingsModal.open('network');
}
@@ -131,4 +153,78 @@ export class App implements OnInit {
async restartToApplyUpdate(): Promise<void> {
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;
hostId: string;
password?: string;
hasPassword?: boolean;
isPrivate: boolean;
createdAt: number;
userCount: number;
@@ -80,6 +81,9 @@ export interface Room {
permissions?: RoomPermissions;
channels?: Channel[];
members?: RoomMember[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface RoomSettings {
@@ -88,6 +92,7 @@ export interface RoomSettings {
topic?: string;
isPrivate: boolean;
password?: string;
hasPassword?: boolean;
maxUsers?: number;
rules?: string[];
}
@@ -265,14 +270,14 @@ export interface ChatEvent {
displayName?: string;
emoji?: string;
reason?: string;
settings?: RoomSettings;
settings?: Partial<RoomSettings>;
permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean;
icon?: string;
iconUpdatedAt?: number;
role?: UserRole;
room?: Room;
room?: Partial<Room>;
channels?: Channel[];
members?: RoomMember[];
ban?: BanEntry;
@@ -292,11 +297,13 @@ export interface ServerInfo {
ownerPublicKey?: string;
userCount: number;
maxUsers: number;
hasPassword?: boolean;
isPrivate: boolean;
tags?: string[];
createdAt: number;
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface JoinRequest {

View File

@@ -5,6 +5,7 @@ import {
signal,
effect
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { take } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { WebRTCService } from './webrtc.service';
@@ -12,6 +13,7 @@ import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
import { DatabaseService } from './database.service';
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
import { ROOM_URL_PATTERN } from '../constants';
import type {
ChatAttachmentAnnouncement,
ChatAttachmentMeta,
@@ -145,9 +147,14 @@ export class AttachmentService {
private readonly webrtc = inject(WebRTCService);
private readonly ngrxStore = inject(Store);
private readonly database = inject(DatabaseService);
private readonly router = inject(Router);
/** Primary index: `messageId → 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. */
updated = signal<number>(0);
@@ -190,6 +197,24 @@ export class AttachmentService {
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 {
@@ -201,6 +226,44 @@ export class AttachmentService {
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. */
async deleteForMessage(messageId: string): Promise<void> {
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
@@ -219,6 +282,7 @@ export class AttachmentService {
}
this.attachmentsByMessage.delete(messageId);
this.messageRoomIds.delete(messageId);
this.clearMessageScopedState(messageId);
if (hadCachedAttachments) {
@@ -276,8 +340,15 @@ export class AttachmentService {
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
*/
registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
if (messageRoomIds) {
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
this.rememberMessageRoom(messageId, roomId);
}
}
const newAttachments: Attachment[] = [];
for (const [messageId, metas] of Object.entries(attachmentMap)) {
@@ -306,6 +377,7 @@ export class AttachmentService {
for (const attachment of newAttachments) {
void this.persistAttachmentMeta(attachment);
this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id);
}
}
}
@@ -375,9 +447,9 @@ export class AttachmentService {
* message to all connected peers.
*
* 1. Each file is assigned a UUID.
* 2. A `file-announce` event is broadcast to peers.
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
* are immediately streamed as chunked base-64.
* 2. A `file-announce` event is broadcast to peers.
* 3. Peers watching the message's server can request any
* auto-download-eligible media on demand.
*
* @param messageId - ID of the parent message.
* @param files - Array of user-selected `File` objects.
@@ -437,10 +509,6 @@ export class AttachmentService {
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) ?? [];
@@ -482,6 +550,7 @@ export class AttachmentService {
this.attachmentsByMessage.set(messageId, list);
this.touch();
void this.persistAttachmentMeta(attachment);
this.queueAutoDownloadsForMessage(messageId, attachment.id);
}
/**
@@ -772,6 +841,38 @@ export class AttachmentService {
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 {
const scopedPrefix = `${messageId}:`;
@@ -867,6 +968,12 @@ export class AttachmentService {
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. */
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
@@ -1167,6 +1274,38 @@ export class AttachmentService {
} 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. */
private async migrateFromLocalStorage(): Promise<void> {
try {

View File

@@ -218,7 +218,6 @@ export class DebuggingService {
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
.trim() || '(empty console call)';
// Use only string args for label/message extraction so that
// stringified object payloads don't pollute the parsed message.
// Object payloads are captured separately via extractConsolePayload.
@@ -226,7 +225,6 @@ export class DebuggingService {
.filter((arg): arg is string => typeof arg === 'string')
.join(' ')
.trim() || rawMessage;
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
const payload = this.extractConsolePayload(args);
const payloadText = payload === undefined

View File

@@ -12,11 +12,7 @@ import {
forkJoin
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import {
ServerInfo,
JoinRequest,
User
} from '../models/index';
import { ServerInfo, User } from '../models/index';
import { v4 as uuidv4 } from 'uuid';
import { environment } from '../../../environments/environment';
@@ -40,6 +36,69 @@ export interface ServerEndpoint {
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. */
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
/** 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.
*/
addServer(server: { name: string; url: string }): void {
addServer(server: { name: string; url: string }): ServerEndpoint {
const sanitisedUrl = this.sanitiseUrl(server.url);
const newEndpoint: ServerEndpoint = {
id: uuidv4(),
@@ -144,6 +203,40 @@ export class ServerDirectoryService {
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
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. */
getApiBaseUrl(): string {
return this.buildApiBaseUrl();
getApiBaseUrl(selector?: ServerSourceSelector): string {
return this.buildApiBaseUrl(selector);
}
/** Get the WebSocket URL derived from the active endpoint. */
getWebSocketUrl(): string {
const active = this.activeServer();
if (!active)
return buildDefaultServerUrl().replace(/^http/, 'ws');
return active.url.replace(/^http/, 'ws');
getWebSocketUrl(selector?: ServerSourceSelector): string {
return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
}
/**
@@ -310,11 +398,11 @@ export class ServerDirectoryService {
}
/** Fetch details for a single server. */
getServer(serverId: string): Observable<ServerInfo | null> {
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.http
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
.get<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
map((server) => this.normalizeServerInfo(server, this.activeServer())),
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => {
console.error('Failed to get server:', error);
return of(null);
@@ -324,10 +412,11 @@ export class ServerDirectoryService {
/** Register a new server listing in the directory. */
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string }
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
.post<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers`, server)
.pipe(
catchError((error) => {
console.error('Failed to register server:', error);
@@ -339,10 +428,15 @@ export class ServerDirectoryService {
/** Update an existing server listing. */
updateServer(
serverId: string,
updates: Partial<ServerInfo> & { currentOwnerId: string }
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.put<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
.put<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`, updates)
.pipe(
catchError((error) => {
console.error('Failed to update server:', error);
@@ -352,9 +446,9 @@ export class ServerDirectoryService {
}
/** Remove a server listing from the directory. */
unregisterServer(serverId: string): Observable<void> {
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.delete<void>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
.delete<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
catchError((error) => {
console.error('Failed to unregister server:', error);
@@ -364,9 +458,9 @@ export class ServerDirectoryService {
}
/** Retrieve users currently connected to a server. */
getServerUsers(serverId: string): Observable<User[]> {
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.http
.get<User[]>(`${this.buildApiBaseUrl()}/servers/${serverId}/users`)
.get<User[]>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/users`)
.pipe(
catchError((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. */
requestJoin(
request: JoinRequest
): Observable<{ success: boolean; signalingUrl?: string }> {
request: ServerJoinAccessRequest,
selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.http
.post<{ success: boolean; signalingUrl?: string }>(
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
.post<ServerJoinAccessResponse>(
`${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`,
request
)
.pipe(
@@ -392,10 +487,86 @@ export class ServerDirectoryService {
);
}
/** Notify the directory that a user has left a server. */
notifyLeave(serverId: string, userId: string): Observable<void> {
/** Create an expiring invite link for a server. */
createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
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(
catchError((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
* slashes and accidental `/api` suffixes.
*/
private buildApiBaseUrl(): string {
const active = this.activeServer();
const rawUrl = active ? active.url : buildDefaultServerUrl();
let base = rawUrl.replace(/\/+$/, '');
if (base.toLowerCase().endsWith('/api')) {
base = base.slice(0, -4);
}
return `${base}/api`;
private buildApiBaseUrl(selector?: ServerSourceSelector): string {
return `${this.resolveBaseServerUrl(selector)}/api`;
}
/** Strip trailing slashes and `/api` suffix from a URL. */
@@ -456,6 +618,26 @@ export class ServerDirectoryService {
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[]`
* response shapes from the directory API.
@@ -560,45 +742,44 @@ export class ServerDirectoryService {
source?: ServerEndpoint | null
): ServerInfo {
const candidate = server as Record<string, unknown>;
const userCount = typeof candidate['userCount'] === 'number'
? candidate['userCount']
: (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;
const sourceName = this.getStringValue(candidate['sourceName']);
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
return {
id: typeof candidate['id'] === 'string' ? candidate['id'] : '',
name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server',
description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined,
topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined,
hostName:
typeof candidate['hostName'] === 'string'
? candidate['hostName']
: (typeof candidate['sourceName'] === 'string'
? candidate['sourceName']
: (source?.name ?? 'Unknown API')),
ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined,
ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined,
ownerPublicKey:
typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined,
userCount,
maxUsers,
isPrivate,
id: this.getStringValue(candidate['id']) ?? '',
name: this.getStringValue(candidate['name']) ?? 'Unnamed server',
description: this.getStringValue(candidate['description']),
topic: this.getStringValue(candidate['topic']),
hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API',
ownerId: this.getStringValue(candidate['ownerId']),
ownerName: this.getStringValue(candidate['ownerName']),
ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']),
userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])),
maxUsers: this.getNumberValue(candidate['maxUsers']),
hasPassword: this.getBooleanValue(candidate['hasPassword']),
isPrivate: this.getBooleanValue(candidate['isPrivate']),
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
createdAt: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(),
sourceId:
typeof candidate['sourceId'] === 'string'
? candidate['sourceId']
: source?.id,
sourceName:
typeof candidate['sourceName'] === 'string'
? candidate['sourceName']
: source?.name
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
sourceName: sourceName ?? source?.name,
sourceUrl: sourceUrl
? this.sanitiseUrl(sourceUrl)
: (source ? this.sanitiseUrl(source.url) : undefined)
};
}
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. */
private loadEndpoints(): void {
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);

View File

@@ -1,13 +1,13 @@
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' })
export class SettingsModalService {
readonly isOpen = signal(false);
readonly activePage = signal<SettingsPage>('network');
readonly activePage = signal<SettingsPage>('general');
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.targetServerId.set(serverId ?? null);
this.isOpen.set(true);

View File

@@ -71,6 +71,7 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
oderId?: string;
serverTime?: number;
serverId?: string;
serverIds?: string[];
users?: SignalingUserSummary[];
displayName?: string;
fromUserId?: string;
@@ -92,8 +93,8 @@ export class WebRTCService implements OnDestroy {
private activeServerId: string | null = null;
/** The server ID where voice is currently active, or `null` when not in voice. */
private voiceServerId: string | null = null;
/** Maps each remote peer ID to the server they were discovered from. */
private readonly peerServerMap = new Map<string, string>();
/** Maps each remote peer ID to the shared servers they currently belong to. */
private readonly peerServerMap = new Map<string, Set<string>>();
private readonly serviceDestroyed$ = new Subject<void>();
private remoteScreenShareRequestsEnabled = false;
private readonly desiredRemoteScreenSharePeers = new Set<string>();
@@ -275,6 +276,7 @@ export class WebRTCService implements OnDestroy {
this.peerManager.peerDisconnected$.subscribe((peerId) => {
this.activeRemoteScreenSharePeers.delete(peerId);
this.peerServerMap.delete(peerId);
this.screenShareManager.clearScreenShareRequest(peerId);
});
@@ -349,6 +351,10 @@ export class WebRTCService implements OnDestroy {
if (!user.oderId)
continue;
if (message.serverId) {
this.trackPeerInServer(user.oderId, message.serverId);
}
const existing = this.peerManager.activePeerConnections.get(user.oderId);
const healthy = this.isPeerHealthy(existing);
@@ -367,10 +373,6 @@ export class WebRTCService implements OnDestroy {
this.peerManager.createPeerConnection(user.oderId, true);
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,
oderId: message.oderId
});
if (message.oderId && message.serverId) {
this.trackPeerInServer(message.oderId, message.serverId);
}
}
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
@@ -389,8 +395,16 @@ export class WebRTCService implements OnDestroy {
});
if (message.oderId) {
this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId);
const hasRemainingSharedServers = Array.isArray(message.serverIds)
? this.replacePeerSharedServers(message.oderId, message.serverIds)
: (message.serverId
? this.untrackPeerFromServer(message.oderId, message.serverId)
: false);
if (!hasRemainingSharedServers) {
this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId);
}
}
}
@@ -404,7 +418,7 @@ export class WebRTCService implements OnDestroy {
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
this.peerServerMap.set(fromUserId, offerEffectiveServer);
this.trackPeerInServer(fromUserId, offerEffectiveServer);
}
this.peerManager.handleOffer(fromUserId, sdp);
@@ -441,8 +455,8 @@ export class WebRTCService implements OnDestroy {
private closePeersNotInServer(serverId: string): void {
const peersToClose: string[] = [];
this.peerServerMap.forEach((peerServerId, peerId) => {
if (peerServerId !== serverId) {
this.peerServerMap.forEach((peerServerIds, peerId) => {
if (!peerServerIds.has(serverId)) {
peersToClose.push(peerId);
}
});
@@ -479,6 +493,45 @@ export class WebRTCService implements OnDestroy {
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.
*
@@ -521,6 +574,11 @@ export class WebRTCService implements OnDestroy {
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.
*

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide';
@@ -42,6 +42,7 @@ export class LoginComponent {
private auth = inject(AuthService);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** TrackBy function for server list rendering. */
@@ -72,6 +73,14 @@ export class LoginComponent {
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
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']);
},
error: (err) => {
@@ -82,6 +91,10 @@ export class LoginComponent {
/** Navigate to the registration page. */
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';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide';
@@ -43,6 +43,7 @@ export class RegisterComponent {
private auth = inject(AuthService);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** TrackBy function for server list rendering. */
@@ -74,6 +75,14 @@ export class RegisterComponent {
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
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']);
},
error: (err) => {
@@ -84,6 +93,10 @@ export class RegisterComponent {
/** Navigate to the login page. */
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,
inject,
signal,
DestroyRef
DestroyRef,
effect
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import {
merge,
interval,
@@ -23,6 +26,7 @@ interface TypingSignalingMessage {
type: string;
displayName: string;
oderId: string;
serverId: string;
}
@Component({
@@ -36,6 +40,9 @@ interface TypingSignalingMessage {
})
export class TypingIndicatorComponent {
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[]>([]);
typingOthersCount = signal<number>(0);
@@ -47,8 +54,10 @@ export class TypingIndicatorComponent {
filter((msg): msg is TypingSignalingMessage =>
msg?.type === 'user_typing' &&
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) => {
const now = Date.now();
@@ -77,6 +86,17 @@ export class TypingIndicatorComponent {
merge(typing$, purge$)
.pipe(takeUntilDestroyed(destroyRef))
.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 {

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"
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 {
<ng-icon
name="lucideGlobe"
@@ -153,6 +164,9 @@
<div class="text-muted-foreground">
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
</div>
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
}
</div>
</button>
}
@@ -160,9 +174,9 @@
}
</div>
@if (error()) {
@if (joinErrorMessage() || error()) {
<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>
@@ -181,6 +195,41 @@
</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 -->
@if (showCreateDialog()) {
<div
@@ -263,22 +312,21 @@
>
</div>
@if (newServerPrivate()) {
<div>
<label
for="create-server-password"
class="block text-sm font-medium text-foreground mb-1"
>Password</label
>
<input
type="password"
[(ngModel)]="newServerPassword"
placeholder="Enter 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"
/>
</div>
}
<div>
<label
for="create-server-password"
class="block text-sm font-medium text-foreground mb-1"
>Password (optional)</label
>
<input
type="password"
[(ngModel)]="newServerPassword"
placeholder="Leave blank to allow joining without a 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"
/>
<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 class="flex gap-3 mt-6">

View File

@@ -13,6 +13,7 @@ import { Store } from '@ngrx/store';
import {
debounceTime,
distinctUntilChanged,
firstValueFrom,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -39,6 +40,7 @@ import {
} from '../../core/models/index';
import { SettingsModalService } from '../../core/services/settings-modal.service';
import { DatabaseService } from '../../core/services/database.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ConfirmDialogComponent } from '../../shared';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
@@ -73,6 +75,7 @@ export class ServerSearchComponent implements OnInit {
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
@@ -85,6 +88,11 @@ export class ServerSearchComponent implements OnInit {
bannedServerLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
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
showCreateDialog = signal(false);
@@ -135,16 +143,7 @@ export class ServerSearchComponent implements OnInit {
return;
}
this.store.dispatch(
RoomsActions.joinRoom({
roomId: server.id,
serverInfo: {
name: server.name,
description: server.description,
hostName: server.sourceName || server.hostName
}
})
);
await this.attemptJoinServer(server);
}
/** Open the create-server dialog. */
@@ -176,7 +175,7 @@ export class ServerSearchComponent implements OnInit {
description: this.newServerDescription() || undefined,
topic: this.newServerTopic() || undefined,
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('');
}
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 {
return !!this.bannedServerLookup()[server.id];
}
@@ -223,12 +238,72 @@ export class ServerSearchComponent implements OnInit {
hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
isPrivate: !!room.password,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
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> {
const requestVersion = ++this.banLookupRequestVersion;

View File

@@ -70,6 +70,41 @@
</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()) {
<app-leave-server-dialog
[room]="contextRoom()!"

View File

@@ -7,10 +7,12 @@ import {
signal
} from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs';
import { Room, User } from '../../core/models/index';
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 { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../core/services/database.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import {
ConfirmDialogComponent,
@@ -31,6 +34,7 @@ import {
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
ContextMenuComponent,
@@ -46,6 +50,7 @@ export class ServersRailComponent {
private voiceSession = inject(VoiceSessionService);
private webrtc = inject(WebRTCService);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService);
private banLookupRequestVersion = 0;
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -59,6 +64,10 @@ export class ServersRailComponent {
bannedRoomLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
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)));
constructor() {
@@ -105,27 +114,19 @@ export class ServersRailComponent {
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.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) {
this.voiceSession.setViewingVoiceServer(true);
}
this.prepareVoiceContext(room);
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 }));
} else {
this.store.dispatch(
RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown'
}
})
);
await this.attemptJoinRoom(room);
}
}
@@ -134,6 +135,22 @@ export class ServersRailComponent {
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 {
return !!this.bannedRoomLookup()[room.id];
}
@@ -226,4 +243,105 @@ export class ServersRailComponent {
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()"
/>
</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>
</section>

View File

@@ -53,6 +53,10 @@ export class ServerSettingsComponent {
roomName = '';
roomDescription = '';
isPrivate = signal(false);
hasPassword = signal(false);
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
passwordError = signal<string | null>(null);
roomPassword = '';
maxUsers = 0;
showDeleteConfirm = signal(false);
@@ -72,6 +76,10 @@ export class ServerSettingsComponent {
this.roomName = room.name;
this.roomDescription = room.description || '';
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;
});
}
@@ -86,21 +94,67 @@ export class ServerSettingsComponent {
if (!room)
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(
RoomsActions.updateRoomSettings({
roomId: room.id,
settings: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
}
settings
})
);
this.hasPassword.set(settings.hasPassword ?? this.hasPassword());
this.passwordAction.set('keep');
this.passwordError.set(null);
this.roomPassword = '';
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 {
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">
<h3 class="text-lg font-semibold text-foreground">
@switch (activePage()) {
@case ('general') {
General
}
@case ('network') {
Network
}
@@ -157,6 +160,9 @@
<!-- Scrollable Content Area -->
<div class="flex-1 overflow-y-auto p-6">
@switch (activePage()) {
@case ('general') {
<app-general-settings />
}
@case ('network') {
<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 { WebRTCService } from '../../../core/services/webrtc.service';
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
import { VoiceSettingsComponent } from './voice-settings/voice-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,
FormsModule,
NgIcon,
GeneralSettingsComponent,
NetworkSettingsComponent,
VoiceSettingsComponent,
UpdatesSettingsComponent,
@@ -89,6 +91,9 @@ export class SettingsModalComponent {
activePage = this.modal.activePage;
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'general',
label: 'General',
icon: 'lucideSettings' },
{ id: 'network',
label: 'Network',
icon: 'lucideGlobe' },

View File

@@ -13,6 +13,16 @@
/>
<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()) {
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
{{ roomDescription() }}
@@ -55,8 +65,20 @@
</button>
<!-- Anchored dropdown under the menu button -->
@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()) {
<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
type="button"
(click)="leaveServer()"
@@ -65,6 +87,11 @@
Leave Server
</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>
<button
type="button"

View File

@@ -6,6 +6,7 @@ import {
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { firstValueFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
@@ -14,10 +15,11 @@ import {
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu
lucideMenu,
lucideRefreshCw
} from '@ng-icons/lucide';
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 { selectCurrentUser } from '../../store/users/users.selectors';
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 { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared';
import { Room } from '../../core/models/index';
interface WindowControlsAPI {
minimizeWindow?: () => void;
@@ -50,7 +53,8 @@ type ElectronWindow = Window & {
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu })
lucideMenu,
lucideRefreshCw })
],
templateUrl: './title-bar.component.html'
})
@@ -78,12 +82,22 @@ export class TitleBarComponent {
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUser());
currentRoom = this.store.selectSignal(selectCurrentRoom);
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
inRoom = computed(() => !!this.currentRoom());
roomName = computed(() => this.currentRoom()?.name || '');
roomDescription = computed(() => this.currentRoom()?.description || '');
showRoomReconnectNotice = computed(() =>
this.inRoom() && (
this.isSignalServerReconnecting()
|| this.webrtc.shouldShowConnectionError()
|| this.isReconnecting()
)
);
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
showLeaveConfirm = signal(false);
inviteStatus = signal<string | null>(null);
creatingInvite = signal(false);
/** Minimize the Electron window. */
minimize() {
@@ -122,9 +136,44 @@ export class TitleBarComponent {
/** Toggle the server dropdown menu. */
toggleMenu() {
this.inviteStatus.set(null);
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. */
leaveServer() {
this.openLeaveConfirm();
@@ -170,4 +219,44 @@ export class TitleBarComponent {
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,
signal,
computed,
OnInit,
OnDestroy
OnInit
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Subscription } from 'rxjs';
import {
lucideMic,
lucideMicOff,
@@ -28,6 +26,7 @@ import { ScreenShareQuality } from '../../../core/services/webrtc';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service';
@Component({
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.
* 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 voiceSessionService = inject(VoiceSessionService);
private voicePlayback = inject(VoicePlaybackService);
private store = inject(Store);
currentUser = this.store.selectSignal(selectCurrentUser);
@@ -75,8 +75,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
askScreenShareQuality = signal(true);
showScreenShareQualityDialog = signal(false);
private stateSubscription: Subscription | null = null;
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
ngOnInit(): void {
// Sync mute/deafen state from webrtc service
@@ -84,10 +82,15 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
this.isDeafened.set(this.webrtcService.isDeafened());
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
this.syncScreenShareSettings();
}
ngOnDestroy(): void {
this.stateSubscription?.unsubscribe();
const settings = loadVoiceSettingsFromStorage();
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. */
@@ -117,6 +120,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
toggleDeafen(): void {
this.isDeafened.update((current) => !current);
this.webrtcService.toggleDeafen(this.isDeafened());
this.voicePlayback.updateDeafened(this.isDeafened());
// When deafening, also mute
if (this.isDeafened() && !this.isMuted()) {
@@ -189,6 +193,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
// Disable voice
this.webrtcService.disableVoice();
this.voicePlayback.teardownAll();
this.voicePlayback.updateDeafened(false);
// Update user voice state in store
const user = this.currentUser();

View File

@@ -58,8 +58,31 @@ export class VoicePlaybackService {
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput()
? 'default'
: null;
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 {
@@ -158,6 +181,14 @@ export class VoicePlaybackService {
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:
*

View File

@@ -10,7 +10,6 @@ import {
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Subscription } from 'rxjs';
import {
lucideMic,
lucideMicOff,
@@ -76,7 +75,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private voicePlayback = inject(VoicePlaybackService);
private store = inject(Store);
private settingsModal = inject(SettingsModalService);
private remoteStreamSubscription: Subscription | null = null;
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -110,56 +108,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isDeafened: this.isDeafened()
};
}
private voiceConnectedSubscription: Subscription | null = null;
async ngOnInit(): Promise<void> {
await this.loadAudioDevices();
// Load persisted voice settings and apply
this.loadSettings();
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 {
if (this.isConnected()) {
this.disconnect();
if (!this.webrtcService.isVoiceConnected()) {
this.voicePlayback.teardownAll();
}
this.voicePlayback.teardownAll();
this.remoteStreamSubscription?.unsubscribe();
this.voiceConnectedSubscription?.unsubscribe();
}
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)
this.webrtcService.disableVoice();
this.voicePlayback.teardownAll();
this.voicePlayback.updateDeafened(false);
const user = this.currentUser();

View File

@@ -84,6 +84,17 @@ export class DebugConsoleToolbarComponent {
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 {
this.activeTabChange.emit(tab);
}
@@ -138,17 +149,6 @@ export class DebugConsoleToolbarComponent {
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 {
return this.detached() ? 'Dock' : 'Detach';
}

View File

@@ -15,6 +15,10 @@ export interface DebugExportEnvironment {
userId: string;
}
interface DebugConsoleElectronApi {
linuxDisplayServer?: string;
}
@Injectable({ providedIn: 'root' })
export class DebugConsoleEnvironmentService {
private readonly store = inject(Store);
@@ -113,19 +117,24 @@ export class DebugConsoleEnvironmentService {
if (!navigator.userAgent.includes('Linux'))
return 'N/A';
const electronDisplayServer = this.readElectronDisplayServer();
if (electronDisplayServer)
return electronDisplayServer;
try {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('wayland'))
return 'Wayland';
if (ua.includes('x11'))
return 'X11';
const isOzone = ua.includes('ozone');
if (isOzone)
return 'Ozone (Wayland likely)';
if (ua.includes('x11'))
return 'X11';
} catch {
// Ignore
}
@@ -133,11 +142,22 @@ export class DebugConsoleEnvironmentService {
return this.detectDisplayServerFromEnv();
}
private readElectronDisplayServer(): string | null {
try {
const displayServer = this.getElectronApi()?.linuxDisplayServer;
return typeof displayServer === 'string' && displayServer.trim().length > 0
? displayServer
: null;
} catch {
return null;
}
}
private detectDisplayServerFromEnv(): string {
try {
// Electron may expose env vars
const api = this.getElectronApi() as
Record<string, unknown> | null;
const api = this.getElectronApi();
if (!api)
return 'Unknown (Linux)';
@@ -201,10 +221,10 @@ export class DebugConsoleEnvironmentService {
}
}
private getElectronApi(): Record<string, unknown> | null {
private getElectronApi(): DebugConsoleElectronApi | null {
try {
const win = window as Window &
{ electronAPI?: Record<string, unknown> };
{ electronAPI?: DebugConsoleElectronApi };
return win.electronAPI ?? null;
} catch {

View File

@@ -24,10 +24,16 @@
<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>
<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
</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.
</p>
</div>
@@ -55,7 +61,11 @@
</label>
</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
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"
@@ -129,7 +139,11 @@
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<span class="screen-share-source-picker__preview">
<img [ngSrc]="source.thumbnail" [alt]="source.name" fill />
<img
[ngSrc]="source.thumbnail"
[alt]="source.name"
fill
/>
</span>
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
@@ -156,13 +170,13 @@
} @else {
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
<div>
<p class="text-sm font-medium text-foreground">
No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available
</p>
<p class="text-sm font-medium text-foreground">No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ activeTab() === 'screen'
? 'No displays were reported by Electron right now.'
: 'Restore the window you want to share and try again.' }}
{{
activeTab() === 'screen'
? 'No displays were reported by Electron right now.'
: 'Restore the window you want to share and try again.'
}}
</p>
</div>
</div>

View File

@@ -256,7 +256,10 @@ function handleSyncBatch(
return EMPTY;
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(
@@ -277,6 +280,8 @@ async function processSyncBatch(
const toUpsert: Message[] = [];
for (const incoming of event.messages) {
attachments.rememberMessageRoom(incoming.id, incoming.roomId);
const { message, changed } = await mergeIncomingMessage(incoming, db);
if (incoming.isDeleted) {
@@ -292,40 +297,31 @@ async function processSyncBatch(
}
if (hasAttachmentMetaMap(event.attachments)) {
requestMissingImages(event.attachments, attachments);
queueWatchedAttachmentDownloads(event.attachments, attachments);
}
return toUpsert;
}
/** Auto-requests any unavailable image attachments from any connected peer. */
function requestMissingImages(
/** Queue best-effort auto-downloads for watched-room attachments. */
function queueWatchedAttachmentDownloads(
attachmentMap: AttachmentMetaMap,
attachments: AttachmentService
): void {
for (const [msgId, metas] of Object.entries(attachmentMap)) {
for (const meta of metas) {
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);
}
}
for (const msgId of Object.keys(attachmentMap)) {
attachments.queueAutoDownloadsForMessage(msgId);
}
}
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
function handleChatMessage(
event: IncomingMessageEvent,
{ db, debugging, currentUser }: IncomingMessageContext
{
db,
debugging,
attachments,
currentUser
}: IncomingMessageContext
): Observable<Action> {
const msg = event.message;
@@ -340,6 +336,8 @@ function handleChatMessage(
if (isOwnMessage)
return EMPTY;
attachments.rememberMessageRoom(msg.id, msg.roomId);
trackBackgroundOperation(
db.saveMessage(msg),
debugging,
@@ -492,6 +490,11 @@ function handleFileAnnounce(
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileAnnounce(event);
if (event.messageId) {
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
}
return EMPTY;
}

View File

@@ -2,7 +2,7 @@
* Sync-lifecycle effects for the messages store slice.
*
* 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.
*
* Extracted from the monolithic MessagesEffects to keep each
@@ -33,7 +33,7 @@ import {
exhaustMap,
switchMap,
repeat,
takeUntil
startWith
} from 'rxjs/operators';
import { MessagesActions } from './messages.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.
*/
joinRoomSyncKickoff$ = createEffect(
roomActivationSyncKickoff$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess),
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ room }, currentRoom]) => {
const activeRoom = currentRoom || room;
@@ -152,63 +152,83 @@ export class MessagesSyncEffects {
{ 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.
* Sends inventory requests to all connected peers.
* Sends inventory requests to all connected peers for the active room.
*/
periodicSyncPoll$ = createEffect(() =>
timer(SYNC_POLL_FAST_MS).pipe(
repeat({
delay: () =>
timer(
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
)
}),
takeUntil(this.syncReset$),
withLatestFrom(this.store.select(selectCurrentRoom)),
filter(
([, room]) =>
!!room && this.webrtc.getConnectedPeers().length > 0
),
exhaustMap(([, room]) => {
const peers = this.webrtc.getConnectedPeers();
this.syncReset$.pipe(
startWith(undefined),
switchMap(() =>
timer(SYNC_POLL_FAST_MS).pipe(
repeat({
delay: () =>
timer(
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
)
}),
withLatestFrom(this.store.select(selectCurrentRoom)),
filter(
([, room]) =>
!!room && this.webrtc.getConnectedPeers().length > 0
),
exhaustMap(([, room]) => {
const peers = this.webrtc.getConnectedPeers();
if (!room || peers.length === 0) {
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
});
}
if (!room || peers.length === 0) {
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 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 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) => {
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 });
}),
catchError((error) =>
@@ -104,6 +110,8 @@ export class MessagesEffects {
replyToId
};
this.attachments.rememberMessageRoom(message.id, message.roomId);
this.trackBackgroundOperation(
this.db.saveMessage(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 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 Failure': props<{ error: string }>(),
@@ -67,6 +67,7 @@ export const RoomsActions = createActionGroup({
'Rename Channel': props<{ channelId: string; name: string }>(),
'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';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import { ROOM_URL_PATTERN } from '../../core/constants';
import {
findRoomMember,
removeRoomMember,
@@ -95,6 +96,7 @@ function isWrongServer(
interface RoomPresenceSignalingMessage {
type: string;
reason?: string;
serverId?: string;
users?: { oderId: string; displayName: string }[];
oderId?: string;
@@ -139,7 +141,6 @@ export class RoomsEffects {
searchServers$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.searchServers),
debounceTime(300),
switchMap(({ query }) =>
this.serverDirectory.searchServers(query).pipe(
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. */
createRoom$ = createEffect(() =>
this.actions$.pipe(
@@ -159,17 +187,23 @@ export class RoomsEffects {
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
}
const activeEndpoint = this.serverDirectory.activeServer();
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
const room: Room = {
id: uuidv4(),
name,
description,
topic,
hostId: currentUser.id,
password: normalizedPassword || undefined,
hasPassword: normalizedPassword.length > 0,
isPrivate: isPrivate ?? false,
password,
createdAt: Date.now(),
userCount: 1,
maxUsers: 50
maxUsers: 50,
sourceId: activeEndpoint?.id,
sourceName: activeEndpoint?.name,
sourceUrl: activeEndpoint?.url
};
// Save to local DB
@@ -184,6 +218,8 @@ export class RoomsEffects {
ownerId: currentUser.id,
ownerPublicKey: currentUser.oderId,
hostName: currentUser.displayName,
password: normalizedPassword || null,
hasPassword: normalizedPassword.length > 0,
isPrivate: room.isPrivate,
userCount: 1,
maxUsers: room.maxUsers || 50,
@@ -216,8 +252,35 @@ export class RoomsEffects {
// First check local DB
return from(this.db.getRoom(roomId)).pipe(
switchMap((room) => {
const sourceSelector = serverInfo
? {
sourceId: serverInfo.sourceId,
sourceUrl: serverInfo.sourceUrl
}
: undefined;
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
@@ -227,11 +290,14 @@ export class RoomsEffects {
name: serverInfo.name,
description: serverInfo.description,
hostId: '', // Unknown, will be determined via signaling
isPrivate: !!password,
password,
hasPassword: !!serverInfo.hasPassword,
isPrivate: !!serverInfo.isPrivate,
createdAt: Date.now(),
userCount: 1,
maxUsers: 50
maxUsers: 50,
sourceId: serverInfo.sourceId,
sourceName: serverInfo.sourceName,
sourceUrl: serverInfo.sourceUrl
};
// Save to local DB for future reference
@@ -240,7 +306,7 @@ export class RoomsEffects {
}
// Try to get room info from server
return this.serverDirectory.getServer(roomId).pipe(
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
switchMap((serverData) => {
if (serverData) {
const newRoom: Room = {
@@ -248,11 +314,14 @@ export class RoomsEffects {
name: serverData.name,
description: serverData.description,
hostId: serverData.ownerId || '',
hasPassword: !!serverData.hasPassword,
isPrivate: serverData.isPrivate,
password,
createdAt: serverData.createdAt || Date.now(),
userCount: serverData.userCount,
maxUsers: serverData.maxUsers
maxUsers: serverData.maxUsers,
sourceId: serverData.sourceId,
sourceName: serverData.sourceName,
sourceUrl: serverData.sourceUrl
};
this.db.saveRoom(newRoom);
@@ -278,28 +347,13 @@ export class RoomsEffects {
() =>
this.actions$.pipe(
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([{ room }, user]) => {
const wsUrl = this.serverDirectory.getWebSocketUrl();
const oderId = user?.oderId || this.webrtc.peerId();
const displayName = user?.displayName || 'Anonymous';
// Check if already connected to signaling server
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: () => {}
});
}
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
tap(([
{ room },
user,
savedRooms
]) => {
this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms);
this.router.navigate(['/room', room.id]);
})
@@ -311,8 +365,12 @@ export class RoomsEffects {
viewServer$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.viewServer),
withLatestFrom(this.store.select(selectCurrentUser)),
switchMap(([{ room }, user]) => {
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
switchMap(([
{ room },
user,
savedRooms
]) => {
if (!user) {
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
}
@@ -325,10 +383,7 @@ export class RoomsEffects {
const oderId = user.oderId || this.webrtc.peerId();
if (this.webrtc.isConnected()) {
this.webrtc.setCurrentServer(room.id);
this.webrtc.switchServer(room.id, oderId);
}
this.connectToRoomSignaling(room, user, oderId, savedRooms);
this.router.navigate(['/room', room.id]);
return of(RoomsActions.viewServerSuccess({ room }));
@@ -432,8 +487,12 @@ export class RoomsEffects {
this.serverDirectory.updateServer(roomId, {
currentOwnerId: currentUser.id,
actingRole: 'host',
ownerId: nextHostId,
ownerPublicKey: nextHostOderId
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
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
this.db.deleteRoom(roomId);
@@ -485,10 +553,11 @@ export class RoomsEffects {
if (!room)
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const canManageCurrentRoom = currentRoom?.id === room.id && (currentUser.role === 'host' || currentUser.role === 'admin');
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
const isOwner = currentUserRole === 'host';
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin';
if (!isOwner && !canManageCurrentRoom) {
if (!canManageRoom) {
return of(
RoomsActions.updateRoomSettingsFailure({
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 = {
name: settings.name ?? room.name,
description: settings.description ?? room.description,
topic: settings.topic ?? room.topic,
isPrivate: settings.isPrivate ?? room.isPrivate,
password: settings.password ?? room.password,
password: hasPasswordUpdate ? (normalizedPassword || '') : room.password,
hasPassword: nextHasPassword,
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({
type: 'room-settings-update',
roomId: room.id,
settings: updatedSettings
settings: sharedSettings
});
if (isOwner) {
if (canManageRoom) {
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
actingRole: currentUserRole ?? undefined,
name: updatedSettings.name,
description: updatedSettings.description,
isPrivate: updatedSettings.isPrivate,
maxUsers: updatedSettings.maxUsers
maxUsers: updatedSettings.maxUsers,
password: hasPasswordUpdate ? (normalizedPassword || null) : undefined
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
@@ -713,7 +809,11 @@ export class RoomsEffects {
})
);
return [UsersActions.clearUsers(), ...joinActions];
return [
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
UsersActions.clearUsers(),
...joinActions
];
}
case 'user_joined': {
@@ -729,6 +829,7 @@ export class RoomsEffects {
};
return [
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
UsersActions.userJoined({
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
})
@@ -743,7 +844,17 @@ export class RoomsEffects {
return EMPTY;
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:
@@ -945,14 +1056,20 @@ export class RoomsEffects {
description: typeof room.description === 'string' ? room.description : undefined,
topic: typeof room.topic === 'string' ? room.topic : 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,
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
icon: typeof room.icon === 'string' ? room.icon : undefined,
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
permissions: room.permissions ? { ...room.permissions } : 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, {
type: 'server-state-full',
roomId: room.id,
room,
room: this.sanitizeRoomSnapshot(room),
bans
});
}),
@@ -1075,7 +1192,11 @@ export class RoomsEffects {
description: settings.description ?? room.description,
topic: settings.topic ?? room.topic,
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
}
})
@@ -1196,6 +1317,132 @@ export class RoomsEffects {
{ 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 {
return localStorage.getItem('metoyou_currentUserId');
}

View File

@@ -45,6 +45,7 @@ function deduplicateRooms(rooms: Room[]): Room[] {
function enrichRoom(room: Room): Room {
return {
...room,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
channels: room.channels || defaultChannels(),
members: pruneRoomMembers(room.members || [])
};
@@ -81,6 +82,8 @@ export interface RoomsState {
isConnecting: boolean;
/** Whether the user is connected to a room. */
isConnected: boolean;
/** Whether the current room is using locally cached data while reconnecting. */
isSignalServerReconnecting: boolean;
/** Whether rooms are being loaded from local storage. */
loading: boolean;
/** Most recent error message, if any. */
@@ -97,6 +100,7 @@ export const initialState: RoomsState = {
isSearching: false,
isConnecting: false,
isConnected: false,
isSignalServerReconnecting: false,
loading: false,
error: null,
activeChannelId: 'general'
@@ -158,6 +162,7 @@ export const roomsReducer = createReducer(
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
isConnected: true,
activeChannelId: 'general'
};
@@ -184,6 +189,7 @@ export const roomsReducer = createReducer(
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isSignalServerReconnecting: false,
isConnected: true,
activeChannelId: 'general'
};
@@ -205,6 +211,7 @@ export const roomsReducer = createReducer(
...state,
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
isConnecting: false,
isConnected: false
})),
@@ -252,7 +259,13 @@ export const roomsReducer = createReducer(
description: settings.description,
topic: settings.topic,
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
});
@@ -272,6 +285,7 @@ export const roomsReducer = createReducer(
// Delete room
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
@@ -279,6 +293,7 @@ export const roomsReducer = createReducer(
// Forget room (local only)
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
...state,
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})),
@@ -288,6 +303,7 @@ export const roomsReducer = createReducer(
...state,
currentRoom: enrichRoom(room),
savedRooms: upsertRoom(state.savedRooms, room),
isSignalServerReconnecting: false,
isConnected: true
})),
@@ -296,6 +312,7 @@ export const roomsReducer = createReducer(
...state,
currentRoom: null,
roomSettings: null,
isSignalServerReconnecting: false,
isConnected: false
})),
@@ -360,6 +377,11 @@ export const roomsReducer = createReducer(
isConnecting
})),
on(RoomsActions.setSignalServerReconnecting, (state, { isReconnecting }) => ({
...state,
isSignalServerReconnecting: isReconnecting
})),
// Channel management
on(RoomsActions.selectChannel, (state, { channelId }) => ({
...state,

View File

@@ -26,6 +26,10 @@ export const selectIsConnected = createSelector(
selectRoomsState,
(state) => state.isConnected
);
export const selectIsSignalServerReconnecting = createSelector(
selectRoomsState,
(state) => state.isSignalServerReconnecting
);
export const selectRoomsError = createSelector(
selectRoomsState,
(state) => state.error

View File

@@ -33,6 +33,7 @@ import {
} from './users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import {
BanEntry,
@@ -56,6 +57,7 @@ export class UsersEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService);
private webrtc = inject(WebRTCService);
// Load current user from storage
@@ -162,24 +164,40 @@ export class UsersEffects {
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: userId,
roomId: room.id,
kickedBy: currentUser.id
});
return this.serverDirectory.kickServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to revoke server membership on kick:', error);
return of(void 0);
}),
mergeMap(() => {
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: userId,
roomId: room.id,
kickedBy: currentUser.id
});
return currentRoom?.id === room.id
? [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }),
UsersActions.kickUserSuccess({ userId,
roomId: room.id })
]
: of(
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
);
return currentRoom?.id === room.id
? [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }),
UsersActions.kickUserSuccess({ userId,
roomId: room.id })
]
: of(
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
);
})
);
})
)
);
@@ -228,32 +246,52 @@ export class UsersEffects {
timestamp: Date.now()
};
return from(this.db.saveBan(ban)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: userId,
roomId: room.id,
bannedBy: currentUser.id,
ban
});
return this.serverDirectory.banServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId,
banId: ban.oderId,
displayName: ban.displayName,
reason,
expiresAt
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to persist server ban:', error);
return of(void 0);
}),
mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
];
switchMap(() =>
from(this.db.saveBan(ban)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
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) {
actions.push(UsersActions.banUserSuccess({ userId,
roomId: room.id,
ban }));
}
if (currentRoom?.id === room.id) {
actions.push(UsersActions.banUserSuccess({ userId,
roomId: room.id,
ban }));
}
return actions;
}),
catchError(() => EMPTY)
return actions;
}),
catchError(() => EMPTY)
)
)
);
})
)
@@ -279,16 +317,32 @@ export class UsersEffects {
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
return EMPTY;
return from(this.db.removeBan(oderId)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'unban',
roomId: room.id,
banOderId: oderId
});
return this.serverDirectory.unbanServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
banId: oderId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to remove server ban:', error);
return of(void 0);
}),
map(() => UsersActions.unbanUserSuccess({ oderId })),
catchError(() => EMPTY)
switchMap(() =>
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;
}
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 {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);

107
tools/launch-electron.js Normal file
View File

@@ -0,0 +1,107 @@
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) {
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
if (sessionType === 'wayland') {
return true;
}
return String(env.WAYLAND_DISPLAY || '').trim().length > 0;
}
function hasSwitch(args, switchName) {
const normalizedSwitch = `--${switchName}`;
return args.some((arg) => arg === normalizedSwitch || arg.startsWith(`${normalizedSwitch}=`));
}
function resolveElectronBinary() {
const electronModule = require('electron');
if (typeof electronModule === 'string') {
return electronModule;
}
if (electronModule && typeof electronModule.default === 'string') {
return electronModule.default;
}
throw new Error('Could not resolve the Electron executable.');
}
function buildElectronArgs(argv) {
const args = [...argv];
if (
process.platform === 'linux'
&& isWaylandSession(process.env)
&& !hasSwitch(args, 'ozone-platform')
) {
args.push('--ozone-platform=wayland');
}
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() {
const electronBinary = resolveElectronBinary();
const args = buildElectronArgs(process.argv.slice(2));
const child = spawn(electronBinary, args, {
env: buildChildEnv(process.env),
stdio: 'inherit'
});
child.on('error', (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
child.on('exit', (code, signal) => {
if (code === DEV_SINGLE_INSTANCE_EXIT_CODE) {
keepProcessAliveForExistingInstance();
return;
}
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
}
main();