Compare commits
8 Commits
2b6e477c9a
...
v1.0.68
| Author | SHA1 | Date | |
|---|---|---|---|
| c862c2fe03 | |||
| 4faa62864d | |||
| 1cdd1c5d2b | |||
| 141de64767 | |||
| eb987ac672 | |||
| f8fd78d21a | |||
| 150c45c31a | |||
| 00adf39121 |
@@ -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
|
||||
|
||||
@@ -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
2
dev.sh
@@ -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"
|
||||
|
||||
58
electron/app/auto-start.ts
Normal file
58
electron/app/auto-start.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { app } from 'electron';
|
||||
import AutoLaunch from 'auto-launch';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
let autoLauncher: AutoLaunch | null = null;
|
||||
|
||||
function resolveLaunchPath(): string {
|
||||
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
||||
const appImagePath = process.platform === 'linux'
|
||||
? String(process.env['APPIMAGE'] || '').trim()
|
||||
: '';
|
||||
|
||||
return appImagePath || process.execPath;
|
||||
}
|
||||
|
||||
function getAutoLauncher(): AutoLaunch | null {
|
||||
if (!app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!autoLauncher) {
|
||||
autoLauncher = new AutoLaunch({
|
||||
name: app.getName(),
|
||||
path: resolveLaunchPath()
|
||||
});
|
||||
}
|
||||
|
||||
return autoLauncher;
|
||||
}
|
||||
|
||||
async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
||||
const launcher = getAutoLauncher();
|
||||
|
||||
if (!launcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentlyEnabled = await launcher.isEnabled();
|
||||
|
||||
if (currentlyEnabled === enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
await launcher.enable();
|
||||
return;
|
||||
}
|
||||
|
||||
await launcher.disable();
|
||||
}
|
||||
|
||||
export async function synchronizeAutoStartSetting(enabled = readDesktopSettings().autoStart): Promise<void> {
|
||||
try {
|
||||
await setAutoStartEnabled(enabled);
|
||||
} catch {
|
||||
// Auto-launch integration should never block app startup or settings saves.
|
||||
}
|
||||
}
|
||||
121
electron/app/deep-links.ts
Normal file
121
electron/app/deep-links.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { createWindow, getMainWindow } from '../window/create-window';
|
||||
|
||||
const CUSTOM_PROTOCOL = 'toju';
|
||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||
|
||||
let pendingDeepLink: string | null = null;
|
||||
|
||||
function resolveDevSingleInstanceExitCode(): number | null {
|
||||
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
|
||||
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0
|
||||
? parsedValue
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractDeepLink(argv: string[]): string | null {
|
||||
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
|
||||
}
|
||||
|
||||
function focusMainWindow(): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function forwardDeepLink(url: string): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoadingMainFrame()) {
|
||||
pendingDeepLink = url;
|
||||
|
||||
if (app.isReady() && (!mainWindow || mainWindow.isDestroyed())) {
|
||||
void createWindow();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
focusMainWindow();
|
||||
mainWindow.webContents.send('deep-link-received', url);
|
||||
}
|
||||
|
||||
function registerProtocolClient(): void {
|
||||
if (process.defaultApp) {
|
||||
const appEntrypoint = process.argv[1];
|
||||
|
||||
if (appEntrypoint) {
|
||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(appEntrypoint)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL);
|
||||
}
|
||||
|
||||
export function initializeDeepLinkHandling(): boolean {
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
const devExitCode = resolveDevSingleInstanceExitCode();
|
||||
|
||||
if (devExitCode != null) {
|
||||
app.exit(devExitCode);
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
registerProtocolClient();
|
||||
|
||||
const initialDeepLink = extractDeepLink(process.argv);
|
||||
|
||||
if (initialDeepLink) {
|
||||
pendingDeepLink = initialDeepLink;
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
focusMainWindow();
|
||||
|
||||
const deepLink = extractDeepLink(argv);
|
||||
|
||||
if (deepLink) {
|
||||
forwardDeepLink(deepLink);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
forwardDeepLink(url);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function consumePendingDeepLink(): string | null {
|
||||
const deepLink = pendingDeepLink;
|
||||
|
||||
pendingDeepLink = null;
|
||||
|
||||
return deepLink;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './utils/applyUpdates';
|
||||
|
||||
const ROOM_TRANSFORMS: TransformMap = {
|
||||
hasPassword: boolToInt,
|
||||
isPrivate: boolToInt,
|
||||
userCount: (val) => (val ?? 0),
|
||||
permissions: jsonOrNull,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
|
||||
name = 'AddRoomSourceAndPasswordState1000000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
|
||||
await queryRunner.query(`
|
||||
UPDATE "rooms"
|
||||
SET "hasPassword" = CASE
|
||||
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Command, Query } from './cqrs/types';
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_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
50
package-lock.json
generated
@@ -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",
|
||||
|
||||
16
package.json
16
package.json
@@ -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.
@@ -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());
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface ServerPayload {
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
hasPassword?: boolean;
|
||||
passwordHash?: string | null;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_bans')
|
||||
export class ServerBanEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('text')
|
||||
bannedBy!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
displayName!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
reason!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
expiresAt!: number | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export class ServerEntity {
|
||||
@Column('text')
|
||||
ownerPublicKey!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
passwordHash!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isPrivate!: number;
|
||||
|
||||
|
||||
29
server/src/entities/ServerInviteEntity.ts
Normal file
29
server/src/entities/ServerInviteEntity.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_invites')
|
||||
export class ServerInviteEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Column('text')
|
||||
createdBy!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
createdByDisplayName!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Index()
|
||||
@Column('integer')
|
||||
expiresAt!: number;
|
||||
}
|
||||
26
server/src/entities/ServerMembershipEntity.ts
Normal file
26
server/src/entities/ServerMembershipEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_memberships')
|
||||
export class ServerMembershipEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('integer')
|
||||
joinedAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
lastAccessAt!: number;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
export { AuthUserEntity } from './AuthUserEntity';
|
||||
export { ServerEntity } from './ServerEntity';
|
||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
export { ServerBanEntity } from './ServerBanEntity';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ServerAccessControl1000000000001 implements MigrationInterface {
|
||||
name = 'ServerAccessControl1000000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "passwordHash" TEXT`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_memberships" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"joinedAt" INTEGER NOT NULL,
|
||||
"lastAccessAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_serverId" ON "server_memberships" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_userId" ON "server_memberships" ("userId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_invites" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdByDisplayName" TEXT,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"expiresAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_serverId" ON "server_invites" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_expiresAt" ON "server_invites" ("expiresAt")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_bans" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"bannedBy" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"reason" TEXT,
|
||||
"expiresAt" INTEGER,
|
||||
"createdAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_serverId" ON "server_bans" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_userId" ON "server_bans" ("userId")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_bans"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_invites"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_memberships"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "passwordHash"`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||
|
||||
export const serverMigrations = [InitialSchema1000000000000];
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import 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);
|
||||
}
|
||||
|
||||
57
server/src/routes/invite-utils.ts
Normal file
57
server/src/routes/invite-utils.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
function buildOrigin(protocol: string, host: string): string {
|
||||
return `${protocol}://${host}`.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function originFromUrl(url: URL): string {
|
||||
return buildOrigin(url.protocol.replace(':', ''), url.host);
|
||||
}
|
||||
|
||||
export function getRequestOrigin(request: Request): string {
|
||||
const forwardedProtoHeader = request.get('x-forwarded-proto');
|
||||
const forwardedHostHeader = request.get('x-forwarded-host');
|
||||
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
|
||||
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
|
||||
|
||||
return buildOrigin(protocol, host);
|
||||
}
|
||||
|
||||
export function deriveWebAppOrigin(signalOrigin: string): string {
|
||||
const url = new URL(signalOrigin);
|
||||
|
||||
if (url.hostname === 'signal.toju.app' && !url.port) {
|
||||
return 'https://web.toju.app';
|
||||
}
|
||||
|
||||
if (url.hostname.startsWith('signal.')) {
|
||||
url.hostname = url.hostname.replace(/^signal\./, 'web.');
|
||||
|
||||
if (url.port === '3001') {
|
||||
url.port = '4200';
|
||||
}
|
||||
|
||||
return originFromUrl(url);
|
||||
}
|
||||
|
||||
if (url.port === '3001') {
|
||||
url.port = '4200';
|
||||
return originFromUrl(url);
|
||||
}
|
||||
|
||||
return 'https://web.toju.app';
|
||||
}
|
||||
|
||||
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
|
||||
}
|
||||
|
||||
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
const browserOrigin = deriveWebAppOrigin(signalOrigin);
|
||||
|
||||
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||
}
|
||||
|
||||
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||
}
|
||||
331
server/src/routes/invites.ts
Normal file
331
server/src/routes/invites.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { Router } from 'express';
|
||||
import { getUserById } from '../cqrs';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import { getActiveServerInvite } from '../services/server-access.service';
|
||||
import {
|
||||
buildAppInviteUrl,
|
||||
buildBrowserInviteUrl,
|
||||
buildInviteUrl,
|
||||
getRequestOrigin
|
||||
} from './invite-utils';
|
||||
|
||||
export const invitesApiRouter = Router();
|
||||
export const invitePageRouter = Router();
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl: string) {
|
||||
const owner = await getUserById(server.ownerId);
|
||||
const { passwordHash, ...publicServer } = server;
|
||||
|
||||
return {
|
||||
...publicServer,
|
||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||
ownerName: owner?.displayName,
|
||||
sourceUrl,
|
||||
userCount: server.currentUsers
|
||||
};
|
||||
}
|
||||
|
||||
function renderInvitePage(options: {
|
||||
appUrl?: string;
|
||||
browserUrl?: string;
|
||||
error?: string;
|
||||
expiresAt?: number;
|
||||
inviteUrl?: string;
|
||||
isExpired: boolean;
|
||||
ownerName?: string;
|
||||
serverDescription?: string;
|
||||
serverName: string;
|
||||
}) {
|
||||
const expiryLabel = options.expiresAt
|
||||
? new Date(options.expiresAt).toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
: null;
|
||||
const statusLabel = options.isExpired ? 'Expired' : 'Active';
|
||||
const statusColor = options.isExpired ? '#f87171' : '#4ade80';
|
||||
const buttonOpacity = options.isExpired ? 'opacity:0.5;pointer-events:none;' : '';
|
||||
const errorBlock = options.error
|
||||
? `<div class="notice notice-error">${options.error}</div>`
|
||||
: '';
|
||||
const description = options.serverDescription
|
||||
? `<p class="description">${options.serverDescription}</p>`
|
||||
: '<p class="description">You have been invited to join a Toju server.</p>';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Invite to ${options.serverName}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #050816;
|
||||
--bg-soft: rgba(11, 18, 42, 0.78);
|
||||
--card: rgba(15, 23, 42, 0.92);
|
||||
--border: rgba(148, 163, 184, 0.18);
|
||||
--text: #f8fafc;
|
||||
--muted: #cbd5e1;
|
||||
--primary: #8b5cf6;
|
||||
--primary-soft: rgba(139, 92, 246, 0.16);
|
||||
--secondary: rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.28), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(139, 92, 246, 0.24), transparent 30%),
|
||||
linear-gradient(180deg, #050816 0%, #0b1120 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
.shell {
|
||||
width: min(100%, 760px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 28px;
|
||||
background: var(--bg-soft);
|
||||
backdrop-filter: blur(22px);
|
||||
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero {
|
||||
padding: 36px 36px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.8), rgba(15, 23, 42, 0.55));
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: var(--secondary);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: ${statusColor};
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, ${statusColor} 18%, transparent);
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(2rem, 3vw, 3.25rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
max-width: 44rem;
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 28px 36px 36px;
|
||||
}
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
.meta-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--card);
|
||||
padding: 18px;
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.meta-value {
|
||||
margin-top: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.button-primary {
|
||||
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||||
box-shadow: 0 18px 36px rgba(99, 102, 241, 0.28);
|
||||
}
|
||||
.button-secondary {
|
||||
border-color: var(--border);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
.notice {
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.notice-error {
|
||||
border-color: rgba(248, 113, 113, 0.32);
|
||||
background: rgba(127, 29, 29, 0.18);
|
||||
color: #fecaca;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 18px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.footer a {
|
||||
color: #c4b5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #ddd6fe;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.hero, .content { padding-inline: 22px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div class="eyebrow"><span class="status-dot"></span>${statusLabel} invite</div>
|
||||
<h1>Join ${options.serverName}</h1>
|
||||
${description}
|
||||
</section>
|
||||
|
||||
<section class="content">
|
||||
${errorBlock}
|
||||
<div class="meta-grid">
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">Server</div>
|
||||
<div class="meta-value">${options.serverName}</div>
|
||||
</article>
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">Owner</div>
|
||||
<div class="meta-value">${options.ownerName || 'Unknown'}</div>
|
||||
</article>
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">Expires</div>
|
||||
<div class="meta-value">${expiryLabel || 'Expired'}</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="${buttonOpacity}">
|
||||
<a class="button button-primary" href="${options.browserUrl || '#'}">Join in browser</a>
|
||||
<a class="button button-secondary" href="${options.appUrl || '#'}">Open with Toju</a>
|
||||
</div>
|
||||
|
||||
<div class="notice">
|
||||
Invite links bypass private and password restrictions, but banned users still cannot join.
|
||||
If Toju is not installed yet, use the desktop button after installing from <a href="https://toju.app/downloads">toju.app/downloads</a>.
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Share link: <code>${options.inviteUrl || 'Unavailable'}</code></span>
|
||||
<a href="https://toju.app/downloads">Download Toju</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
invitesApiRouter.get('/:id', async (req, res) => {
|
||||
const signalOrigin = getRequestOrigin(req);
|
||||
const bundle = await getActiveServerInvite(req.params['id']);
|
||||
|
||||
if (!bundle) {
|
||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
|
||||
res.json({
|
||||
id: bundle.invite.id,
|
||||
serverId: bundle.invite.serverId,
|
||||
createdAt: bundle.invite.createdAt,
|
||||
expiresAt: bundle.invite.expiresAt,
|
||||
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||
sourceUrl: signalOrigin,
|
||||
createdBy: bundle.invite.createdBy,
|
||||
createdByDisplayName: bundle.invite.createdByDisplayName ?? undefined,
|
||||
isExpired: bundle.invite.expiresAt <= Date.now(),
|
||||
server: await enrichServer(server, signalOrigin)
|
||||
});
|
||||
});
|
||||
|
||||
invitePageRouter.get('/:id', async (req, res) => {
|
||||
const signalOrigin = getRequestOrigin(req);
|
||||
const bundle = await getActiveServerInvite(req.params['id']);
|
||||
|
||||
if (!bundle) {
|
||||
res.status(404).send(renderInvitePage({
|
||||
error: 'This invite has expired or is no longer available.',
|
||||
isExpired: true,
|
||||
serverName: 'Toju server'
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
const owner = await getUserById(server.ownerId);
|
||||
|
||||
res.send(renderInvitePage({
|
||||
serverName: server.name,
|
||||
serverDescription: server.description,
|
||||
ownerName: owner?.displayName,
|
||||
expiresAt: bundle.invite.expiresAt,
|
||||
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||
isExpired: bundle.invite.expiresAt <= Date.now()
|
||||
}));
|
||||
});
|
||||
@@ -1,29 +1,90 @@
|
||||
import { Router } from 'express';
|
||||
import { Response, Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { 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;
|
||||
|
||||
390
server/src/services/server-access.service.ts
Normal file
390
server/src/services/server-access.service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDataSource } from '../db/database';
|
||||
import {
|
||||
ServerBanEntity,
|
||||
ServerEntity,
|
||||
ServerInviteEntity,
|
||||
ServerMembershipEntity
|
||||
} from '../entities';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
|
||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type JoinAccessVia = 'membership' | 'password' | 'invite' | 'public';
|
||||
|
||||
export interface JoinServerAccessResult {
|
||||
joinedBefore: boolean;
|
||||
server: ServerPayload;
|
||||
via: JoinAccessVia;
|
||||
}
|
||||
|
||||
export interface BanServerUserOptions {
|
||||
banId?: string;
|
||||
bannedBy: string;
|
||||
displayName?: string;
|
||||
expiresAt?: number;
|
||||
reason?: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export class ServerAccessError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ServerAccessError';
|
||||
}
|
||||
}
|
||||
|
||||
function getServerRepository() {
|
||||
return getDataSource().getRepository(ServerEntity);
|
||||
}
|
||||
|
||||
function getMembershipRepository() {
|
||||
return getDataSource().getRepository(ServerMembershipEntity);
|
||||
}
|
||||
|
||||
function getInviteRepository() {
|
||||
return getDataSource().getRepository(ServerInviteEntity);
|
||||
}
|
||||
|
||||
function getBanRepository() {
|
||||
return getDataSource().getRepository(ServerBanEntity);
|
||||
}
|
||||
|
||||
function normalizePassword(password?: string | null): string | null {
|
||||
const normalized = password?.trim() ?? '';
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function isServerOwner(server: ServerEntity, userId: string): boolean {
|
||||
return server.ownerId === userId;
|
||||
}
|
||||
|
||||
export function hashServerPassword(password: string): string {
|
||||
return crypto.createHash('sha256').update(password)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function passwordHashForInput(password?: string | null): string | null {
|
||||
const normalized = normalizePassword(password);
|
||||
|
||||
return normalized ? hashServerPassword(normalized) : null;
|
||||
}
|
||||
|
||||
export function buildSignalingUrl(origin: string): string {
|
||||
return origin.replace(/^http/i, 'ws');
|
||||
}
|
||||
|
||||
export async function pruneExpiredServerAccessArtifacts(now: number = Date.now()): Promise<void> {
|
||||
await getInviteRepository()
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('expiresAt <= :now', { now })
|
||||
.execute();
|
||||
|
||||
await getBanRepository()
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('expiresAt IS NOT NULL AND expiresAt <= :now', { now })
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getServerRecord(serverId: string): Promise<ServerEntity | null> {
|
||||
return await getServerRepository().findOne({ where: { id: serverId } });
|
||||
}
|
||||
|
||||
export async function getActiveServerBan(serverId: string, userId: string): Promise<ServerBanEntity | null> {
|
||||
const banRepo = getBanRepository();
|
||||
const ban = await banRepo.findOne({ where: { serverId, userId } });
|
||||
|
||||
if (!ban)
|
||||
return null;
|
||||
|
||||
if (ban.expiresAt && ban.expiresAt <= Date.now()) {
|
||||
await banRepo.delete({ id: ban.id });
|
||||
return null;
|
||||
}
|
||||
|
||||
return ban;
|
||||
}
|
||||
|
||||
export async function isServerUserBanned(serverId: string, userId: string): Promise<boolean> {
|
||||
return !!(await getActiveServerBan(serverId, userId));
|
||||
}
|
||||
|
||||
export async function findServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity | null> {
|
||||
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||
}
|
||||
|
||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||
const repo = getMembershipRepository();
|
||||
const now = Date.now();
|
||||
const existing = await repo.findOne({ where: { serverId, userId } });
|
||||
|
||||
if (existing) {
|
||||
existing.lastAccessAt = now;
|
||||
await repo.save(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entity = repo.create({
|
||||
id: uuidv4(),
|
||||
serverId,
|
||||
userId,
|
||||
joinedAt: now,
|
||||
lastAccessAt: now
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
export async function removeServerMembership(serverId: string, userId: string): Promise<void> {
|
||||
await getMembershipRepository().delete({ serverId, userId });
|
||||
}
|
||||
|
||||
export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise<ServerEntity> {
|
||||
const server = await getServerRecord(serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||
}
|
||||
|
||||
if (await isServerUserBanned(serverId, requesterUserId)) {
|
||||
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot create invites');
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(serverId, requesterUserId);
|
||||
|
||||
if (server.ownerId !== requesterUserId && !membership) {
|
||||
throw new ServerAccessError(403, 'NOT_MEMBER', 'Only joined users can create invites');
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function createServerInvite(
|
||||
serverId: string,
|
||||
createdBy: string,
|
||||
createdByDisplayName?: string
|
||||
): Promise<ServerInviteEntity> {
|
||||
await assertCanCreateInvite(serverId, createdBy);
|
||||
|
||||
const repo = getInviteRepository();
|
||||
const now = Date.now();
|
||||
const invite = repo.create({
|
||||
id: uuidv4(),
|
||||
serverId,
|
||||
createdBy,
|
||||
createdByDisplayName: createdByDisplayName ?? null,
|
||||
createdAt: now,
|
||||
expiresAt: now + SERVER_INVITE_EXPIRY_MS
|
||||
});
|
||||
|
||||
await repo.save(invite);
|
||||
return invite;
|
||||
}
|
||||
|
||||
export async function getActiveServerInvite(
|
||||
inviteId: string
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||
|
||||
if (!invite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (invite.expiresAt <= Date.now()) {
|
||||
await getInviteRepository().delete({ id: invite.id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = await getServerRecord(invite.serverId);
|
||||
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { invite, server };
|
||||
}
|
||||
|
||||
export async function joinServerWithAccess(options: {
|
||||
inviteId?: string;
|
||||
password?: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
}): Promise<JoinServerAccessResult> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const server = await getServerRecord(options.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||
}
|
||||
|
||||
if (await isServerUserBanned(server.id, options.userId)) {
|
||||
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server');
|
||||
}
|
||||
|
||||
if (isServerOwner(server, options.userId)) {
|
||||
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: rowToServer(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
|
||||
if (options.inviteId) {
|
||||
const inviteBundle = await getActiveServerInvite(options.inviteId);
|
||||
|
||||
if (!inviteBundle || inviteBundle.server.id !== server.id) {
|
||||
throw new ServerAccessError(410, 'INVITE_EXPIRED', 'Invite link has expired or is invalid');
|
||||
}
|
||||
|
||||
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: rowToServer(server),
|
||||
via: 'invite'
|
||||
};
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(server.id, options.userId);
|
||||
|
||||
if (membership) {
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: true,
|
||||
server: rowToServer(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
|
||||
if (server.passwordHash) {
|
||||
const passwordHash = passwordHashForInput(options.password);
|
||||
|
||||
if (!passwordHash || passwordHash !== server.passwordHash) {
|
||||
throw new ServerAccessError(403, 'PASSWORD_REQUIRED', 'Password required to join this server');
|
||||
}
|
||||
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
via: 'password'
|
||||
};
|
||||
}
|
||||
|
||||
if (server.isPrivate) {
|
||||
throw new ServerAccessError(403, 'PRIVATE_SERVER', 'Private servers require an invite link');
|
||||
}
|
||||
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
via: 'public'
|
||||
};
|
||||
}
|
||||
|
||||
export async function authorizeWebSocketJoin(serverId: string, userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const server = await getServerRecord(serverId);
|
||||
|
||||
if (!server) {
|
||||
return { allowed: false,
|
||||
reason: 'SERVER_NOT_FOUND' };
|
||||
}
|
||||
|
||||
if (await isServerUserBanned(serverId, userId)) {
|
||||
return { allowed: false,
|
||||
reason: 'BANNED' };
|
||||
}
|
||||
|
||||
if (isServerOwner(server, userId)) {
|
||||
await ensureServerMembership(serverId, userId);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(serverId, userId);
|
||||
|
||||
if (membership) {
|
||||
await ensureServerMembership(serverId, userId);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (!server.isPrivate && !server.passwordHash) {
|
||||
await ensureServerMembership(serverId, userId);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: server.isPrivate ? 'PRIVATE_SERVER' : 'PASSWORD_REQUIRED'
|
||||
};
|
||||
}
|
||||
|
||||
export async function kickServerUser(serverId: string, userId: string): Promise<void> {
|
||||
await removeServerMembership(serverId, userId);
|
||||
}
|
||||
|
||||
export async function leaveServerUser(serverId: string, userId: string): Promise<void> {
|
||||
await removeServerMembership(serverId, userId);
|
||||
}
|
||||
|
||||
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
|
||||
await removeServerMembership(options.serverId, options.userId);
|
||||
|
||||
const repo = getBanRepository();
|
||||
const existing = await repo.findOne({ where: { serverId: options.serverId, userId: options.userId } });
|
||||
|
||||
if (existing) {
|
||||
await repo.delete({ id: existing.id });
|
||||
}
|
||||
|
||||
const entity = repo.create({
|
||||
id: options.banId ?? uuidv4(),
|
||||
serverId: options.serverId,
|
||||
userId: options.userId,
|
||||
bannedBy: options.bannedBy,
|
||||
displayName: options.displayName ?? null,
|
||||
reason: options.reason ?? null,
|
||||
expiresAt: options.expiresAt ?? null,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
export async function unbanServerUser(options: { banId?: string; serverId: string; userId?: string }): Promise<void> {
|
||||
const repo = getBanRepository();
|
||||
|
||||
if (options.banId) {
|
||||
await repo.delete({ id: options.banId, serverId: options.serverId });
|
||||
}
|
||||
|
||||
if (options.userId) {
|
||||
await repo.delete({ serverId: options.serverId, userId: options.userId });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { connectedUsers } from './state';
|
||||
import { 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':
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
102
src/app/app.ts
102
src/app/app.ts
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
85
src/app/features/invite/invite.component.html
Normal file
85
src/app/features/invite/invite.component.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<div class="min-h-full bg-background px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
|
||||
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
|
||||
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
|
||||
<div
|
||||
class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground"
|
||||
>
|
||||
Invite link
|
||||
</div>
|
||||
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
||||
@if (invite()) {
|
||||
Join {{ invite()!.server.name }}
|
||||
} @else {
|
||||
Toju server invite
|
||||
}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
@switch (status()) {
|
||||
@case ('redirecting') {
|
||||
Sign in to continue with this invite.
|
||||
}
|
||||
@case ('joining') {
|
||||
We are connecting you to the invited server.
|
||||
}
|
||||
@case ('error') {
|
||||
This invite could not be completed automatically.
|
||||
}
|
||||
@default {
|
||||
Loading invite details and preparing the correct signal server.
|
||||
}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 px-6 py-8 sm:px-10 lg:grid-cols-[1.2fr,0.8fr]">
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Status</h2>
|
||||
<p class="mt-3 text-lg font-medium text-foreground">{{ message() }}</p>
|
||||
</div>
|
||||
|
||||
@if (invite()) {
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Server</h2>
|
||||
<p class="mt-3 text-xl font-semibold text-foreground">{{ invite()!.server.name }}</p>
|
||||
@if (invite()!.server.description) {
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ invite()!.server.description }}</p>
|
||||
}
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
@if (invite()!.server.isPrivate) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Private</span>
|
||||
}
|
||||
@if (invite()!.server.hasPassword) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
|
||||
}
|
||||
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary"> Expires {{ invite()!.expiresAt | date: 'medium' }} </span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">What happens next</h2>
|
||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<li>• The linked signal server is added to your configured server list if needed.</li>
|
||||
<li>• Invite links bypass private and password restrictions.</li>
|
||||
<li>• Banned users still cannot join through invites.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if (status() === 'error') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="goToSearch()"
|
||||
class="inline-flex w-full items-center justify-center rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Back to server search
|
||||
</button>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
192
src/app/features/invite/invite.component.ts
Normal file
192
src/app/features/invite/invite.component.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ServerDirectoryService, ServerInviteInfo } from '../../core/services/server-directory.service';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { User } from '../../core/models/index';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './invite.component.html'
|
||||
})
|
||||
export class InviteComponent implements OnInit {
|
||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||
readonly invite = signal<ServerInviteInfo | null>(null);
|
||||
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
||||
readonly message = signal('Loading invite…');
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const inviteContext = this.resolveInviteContext();
|
||||
|
||||
if (!inviteContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
|
||||
if (!currentUserId) {
|
||||
await this.redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.joinInvite(inviteContext, currentUserId);
|
||||
} catch (error: unknown) {
|
||||
this.applyInviteError(error);
|
||||
}
|
||||
}
|
||||
|
||||
goToSearch(): void {
|
||||
this.router.navigate(['/search']).catch(() => {});
|
||||
}
|
||||
|
||||
private buildEndpointName(sourceUrl: string): string {
|
||||
try {
|
||||
const url = new URL(sourceUrl);
|
||||
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return 'Signal Server';
|
||||
}
|
||||
}
|
||||
|
||||
private applyInviteError(error: unknown): void {
|
||||
const inviteError = error as {
|
||||
error?: { error?: string; errorCode?: string };
|
||||
};
|
||||
const errorCode = inviteError?.error?.errorCode;
|
||||
const fallbackMessage = inviteError?.error?.error || 'Unable to accept this invite.';
|
||||
|
||||
this.status.set('error');
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.message.set('You are banned from this server and cannot accept this invite.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'INVITE_EXPIRED') {
|
||||
this.message.set('This invite has expired. Ask for a fresh invite link.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.message.set(fallbackMessage);
|
||||
}
|
||||
|
||||
private async hydrateCurrentUser(): Promise<User | null> {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (currentUser) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
const storedUser = await this.databaseService.getCurrentUser();
|
||||
|
||||
if (storedUser) {
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user: storedUser }));
|
||||
}
|
||||
|
||||
return storedUser;
|
||||
}
|
||||
|
||||
private async joinInvite(
|
||||
context: { endpoint: { id: string; name: string }; inviteId: string; sourceUrl: string },
|
||||
currentUserId: string
|
||||
): Promise<void> {
|
||||
const invite = await firstValueFrom(this.serverDirectory.getInvite(context.inviteId, {
|
||||
sourceId: context.endpoint.id,
|
||||
sourceUrl: context.sourceUrl
|
||||
}));
|
||||
|
||||
this.invite.set(invite);
|
||||
this.status.set('joining');
|
||||
this.message.set(`Joining ${invite.server.name}…`);
|
||||
|
||||
const currentUser = await this.hydrateCurrentUser();
|
||||
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: invite.server.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
inviteId: context.inviteId
|
||||
}, {
|
||||
sourceId: context.endpoint.id,
|
||||
sourceUrl: context.sourceUrl
|
||||
}));
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: joinResponse.server.id,
|
||||
serverInfo: {
|
||||
...joinResponse.server,
|
||||
sourceId: context.endpoint.id,
|
||||
sourceName: context.endpoint.name,
|
||||
sourceUrl: context.sourceUrl
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async redirectToLogin(): Promise<void> {
|
||||
this.status.set('redirecting');
|
||||
this.message.set('Redirecting to login…');
|
||||
|
||||
await this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInviteContext(): {
|
||||
endpoint: { id: string; name: string };
|
||||
inviteId: string;
|
||||
sourceUrl: string;
|
||||
} | null {
|
||||
const inviteId = this.route.snapshot.paramMap.get('inviteId')?.trim() || '';
|
||||
const sourceUrl = this.route.snapshot.queryParamMap.get('server')?.trim() || '';
|
||||
|
||||
if (!inviteId || !sourceUrl) {
|
||||
this.status.set('error');
|
||||
this.message.set('This invite link is missing required server information.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||
name: this.buildEndpointName(sourceUrl),
|
||||
url: sourceUrl
|
||||
}, {
|
||||
setActive: !localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: {
|
||||
id: endpoint.id,
|
||||
name: endpoint.name
|
||||
},
|
||||
inviteId,
|
||||
sourceUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,17 @@
|
||||
name="lucideLock"
|
||||
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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()!"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="space-y-6 max-w-xl">
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon
|
||||
name="lucidePower"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Application</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
||||
[class.opacity-60]="!isElectron"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
|
||||
|
||||
@if (isElectron) {
|
||||
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
|
||||
} @else {
|
||||
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="relative inline-flex items-center"
|
||||
[class.cursor-pointer]="isElectron && !savingAutoStart()"
|
||||
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="autoStart()"
|
||||
[disabled]="!isElectron || savingAutoStart()"
|
||||
(change)="onAutoStartChange($event)"
|
||||
id="general-auto-start-toggle"
|
||||
aria-label="Toggle launch on startup"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePower } from '@ng-icons/lucide';
|
||||
|
||||
import { PlatformService } from '../../../../core/services/platform.service';
|
||||
|
||||
interface DesktopSettingsSnapshot {
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
interface GeneralSettingsElectronApi {
|
||||
getDesktopSettings?: () => Promise<DesktopSettingsSnapshot>;
|
||||
setDesktopSettings?: (patch: { autoStart?: boolean }) => Promise<DesktopSettingsSnapshot>;
|
||||
}
|
||||
|
||||
type GeneralSettingsWindow = Window & {
|
||||
electronAPI?: GeneralSettingsElectronApi;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-general-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucidePower
|
||||
})
|
||||
],
|
||||
templateUrl: './general-settings.component.html'
|
||||
})
|
||||
export class GeneralSettingsComponent {
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
autoStart = signal(false);
|
||||
savingAutoStart = signal(false);
|
||||
|
||||
constructor() {
|
||||
if (this.isElectron) {
|
||||
void this.loadDesktopSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async onAutoStartChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const enabled = !!input.checked;
|
||||
const api = this.getElectronApi();
|
||||
|
||||
if (!this.isElectron || !api?.setDesktopSettings) {
|
||||
input.checked = this.autoStart();
|
||||
return;
|
||||
}
|
||||
|
||||
this.savingAutoStart.set(true);
|
||||
|
||||
try {
|
||||
const snapshot = await api.setDesktopSettings({ autoStart: enabled });
|
||||
|
||||
this.autoStart.set(snapshot.autoStart);
|
||||
} catch {
|
||||
input.checked = this.autoStart();
|
||||
} finally {
|
||||
this.savingAutoStart.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.getElectronApi();
|
||||
|
||||
if (!api?.getDesktopSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await api.getDesktopSettings();
|
||||
|
||||
this.autoStart.set(snapshot.autoStart);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getElectronApi(): GeneralSettingsElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as GeneralSettingsWindow).electronAPI ?? null
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
107
tools/launch-electron.js
Normal 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();
|
||||
148
website/package-lock.json
generated
148
website/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@angular/platform-server": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@angular/ssr": "^19.2.21",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tsparticles/angular": "^3.0.0",
|
||||
"@tsparticles/engine": "^3.9.1",
|
||||
@@ -670,6 +671,87 @@
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/localize": {
|
||||
"version": "19.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.2.19.tgz",
|
||||
"integrity": "sha512-FlnungTK9pNDi283j0mhuALRzgVj56vfEH//dM8/9CsNtfpGoBnKBOZl/aN//ShilAnjP1UFN40kYVRxgI1kjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.26.9",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"fast-glob": "3.3.3",
|
||||
"yargs": "^17.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"localize-extract": "tools/bundles/src/extract/cli.js",
|
||||
"localize-migrate": "tools/bundles/src/migrate/cli.js",
|
||||
"localize-translate": "tools/bundles/src/translate/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "19.2.19",
|
||||
"@angular/compiler-cli": "19.2.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/localize/node_modules/@babel/core": {
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz",
|
||||
"integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/generator": "^7.26.9",
|
||||
"@babel/helper-compilation-targets": "^7.26.5",
|
||||
"@babel/helper-module-transforms": "^7.26.0",
|
||||
"@babel/helpers": "^7.26.9",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/traverse": "^7.26.9",
|
||||
"@babel/types": "^7.26.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"json5": "^2.2.3",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/babel"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/localize/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@angular/localize/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "19.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.19.tgz",
|
||||
@@ -4403,6 +4485,19 @@
|
||||
"webpack": "^5.54.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngx-translate/core": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz",
|
||||
"integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=16",
|
||||
"@angular/core": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -6083,6 +6178,59 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
"@types/babel__generator": "*",
|
||||
"@types/babel__template": "*",
|
||||
"@types/babel__traverse": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__generator": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
||||
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__template": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.1.0",
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@angular/platform-server": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@angular/ssr": "^19.2.21",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tsparticles/angular": "^3.0.0",
|
||||
"@tsparticles/engine": "^3.9.1",
|
||||
|
||||
353
website/public/i18n/en.json
Normal file
353
website/public/i18n/en.json
Normal file
@@ -0,0 +1,353 @@
|
||||
{
|
||||
"common": {
|
||||
"brand": "Toju",
|
||||
"os": {
|
||||
"windows": "Windows",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux",
|
||||
"linuxDebian": "Linux (deb)",
|
||||
"archive": "Archive",
|
||||
"web": "Web",
|
||||
"other": "Other"
|
||||
},
|
||||
"actions": {
|
||||
"downloadBrand": "Download Toju",
|
||||
"downloadFor": "Download for {{os}}",
|
||||
"openInBrowser": "Open in Browser",
|
||||
"tryInBrowser": "Try in Browser",
|
||||
"useWebVersion": "Use Web Version",
|
||||
"openWebVersion": "Open web version",
|
||||
"goToDownloads": "Go to downloads",
|
||||
"learnHowItWorks": "Learn how it works",
|
||||
"viewSourceCode": "View source code",
|
||||
"buyUsCoffee": "Buy us a coffee"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"header": {
|
||||
"homeAriaLabel": "Toju home",
|
||||
"beta": "Beta",
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"whatIsToju": "What is Toju?",
|
||||
"downloads": "Downloads",
|
||||
"philosophy": "Our Philosophy"
|
||||
},
|
||||
"supportUs": "Support Us",
|
||||
"useWebVersion": "Use Web Version",
|
||||
"toggleMenu": "Toggle menu"
|
||||
},
|
||||
"footer": {
|
||||
"description": "Free, open-source, peer-to-peer communication. Built by people who believe privacy is a right, not a premium feature.",
|
||||
"sections": {
|
||||
"product": "Product",
|
||||
"community": "Community",
|
||||
"values": "Values"
|
||||
},
|
||||
"links": {
|
||||
"downloads": "Downloads",
|
||||
"webVersion": "Web Version",
|
||||
"whatIsToju": "What is Toju?",
|
||||
"imageGallery": "Image Gallery",
|
||||
"sourceCode": "Source Code",
|
||||
"github": "GitHub",
|
||||
"supportUs": "Support Us",
|
||||
"ourPhilosophy": "Our Philosophy"
|
||||
},
|
||||
"values": {
|
||||
"freeForever": "100% Free Forever",
|
||||
"openSource": "Open Source"
|
||||
},
|
||||
"copyright": "© {{year}} Myxelium. Toju is open-source software.",
|
||||
"viewSourceOnGitea": "View source code on Gitea",
|
||||
"viewProjectOnGitHub": "View the project on GitHub"
|
||||
},
|
||||
"adSlot": {
|
||||
"label": "Advertisement"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"home": {
|
||||
"seo": {
|
||||
"title": "Free Peer-to-Peer Voice, Video & Chat",
|
||||
"description": "Toju is a free, open-source, peer-to-peer communication app. Crystal-clear voice calls, unlimited screen sharing, no file size limits, complete privacy."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Currently in Beta - Free & Open Source",
|
||||
"titleLine1": "Talk freely.",
|
||||
"titleLine2": "Own your voice.",
|
||||
"description": "Crystal-clear voice calls, unlimited screen sharing, and file transfers with no size limits. Peer-to-peer. Private. Completely free.",
|
||||
"version": "Version {{version}}",
|
||||
"allPlatforms": "All platforms"
|
||||
},
|
||||
"features": {
|
||||
"titleLine1": "Everything you need,",
|
||||
"titleLine2": "nothing you don't.",
|
||||
"description": "No bloat. No paywalls. Just the tools to connect with the people who matter.",
|
||||
"items": {
|
||||
"voiceCalls": {
|
||||
"title": "HD Voice Calls",
|
||||
"description": "Crystal-clear audio with built-in noise reduction. Hear every word, not the background. No quality compromises, ever."
|
||||
},
|
||||
"screenSharing": {
|
||||
"title": "Screen Sharing",
|
||||
"description": "Share your screen at full resolution. No time limits, no quality downgrades. Perfect for pair programming, presentations, or showing your epic gameplay."
|
||||
},
|
||||
"fileSharing": {
|
||||
"title": "Unlimited File Sharing",
|
||||
"description": "Send files of any size directly to your friends. No upload limits, no compression. Your files go straight from you to them."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "True Privacy",
|
||||
"description": "Peer-to-peer means your data goes directly between you and your friends. No servers storing your conversations. Your business is your business."
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Open Source",
|
||||
"description": "Every line of code is public. Audit it, modify it, host your own signal server. Full transparency - nothing is hidden."
|
||||
},
|
||||
"free": {
|
||||
"title": "Completely Free",
|
||||
"description": "No premium tiers. No paywalls. No \"starter plans\". Every feature is available to everyone, always. Made with love, not profit margins."
|
||||
}
|
||||
}
|
||||
},
|
||||
"gaming": {
|
||||
"badge": "Built for Gamers",
|
||||
"titleLine1": "Your perfect",
|
||||
"titleLine2": "gaming companion.",
|
||||
"description": "Ultra-low latency voice chat that doesn't eat your bandwidth. Share your screen without frame drops. Send clips and files instantly. All while keeping your CPU free for what matters - winning.",
|
||||
"bullets": {
|
||||
"lowLatency": "Low-latency peer-to-peer voice - no relay servers in the way",
|
||||
"noiseSuppression": "AI-powered noise suppression - keyboard clatter stays out",
|
||||
"screenShare": "Full-resolution screen sharing at high FPS",
|
||||
"fileTransfers": "Send replays and screenshots with no file size limit"
|
||||
},
|
||||
"imageAlt": "Toju gaming screen sharing preview",
|
||||
"caption": "Game on. No limits."
|
||||
},
|
||||
"selfHostable": {
|
||||
"badge": "Self-Hostable",
|
||||
"titleLine1": "Your infrastructure,",
|
||||
"titleLine2": "your rules.",
|
||||
"description": "Toju uses a lightweight coordination server to help peers find each other - that's it. Your actual conversations never touch a server. Want even more control? Run your own coordination server in minutes. Full independence, zero compromises."
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to take back your conversations?",
|
||||
"description": "Join thousands choosing privacy, freedom, and real connection."
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"seo": {
|
||||
"title": "Download Toju",
|
||||
"description": "Download Toju for Windows, Linux, or use the web version. Free peer-to-peer voice chat, screen sharing, and file transfers."
|
||||
},
|
||||
"hero": {
|
||||
"titlePrefix": "Download",
|
||||
"description": "Available for Windows, Linux, and in your browser. Always free, always the full experience."
|
||||
},
|
||||
"recommended": {
|
||||
"badge": "Recommended for you",
|
||||
"title": "Toju for {{os}}",
|
||||
"version": "Version {{version}}",
|
||||
"webVersionPrefix": "Or",
|
||||
"webVersionLink": "use the web version",
|
||||
"webVersionSuffix": "- no download required."
|
||||
},
|
||||
"allPlatforms": {
|
||||
"title": "All platforms",
|
||||
"assetIconAlt": "{{os}} icon"
|
||||
},
|
||||
"previousReleases": {
|
||||
"title": "Previous Releases",
|
||||
"fileCount": "{{count}} files"
|
||||
},
|
||||
"loading": "Fetching releases...",
|
||||
"rss": {
|
||||
"prefix": "Stay updated with our",
|
||||
"link": "RSS feed"
|
||||
}
|
||||
},
|
||||
"gallery": {
|
||||
"seo": {
|
||||
"title": "Toju Image Gallery",
|
||||
"description": "Browse screenshots of Toju and explore the interface for chat, file sharing, voice, and screen sharing."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Image Gallery",
|
||||
"titlePrefix": "A closer look at",
|
||||
"description": "Explore screenshots of the app experience, from voice chat and media sharing to servers, rooms, and full-screen collaboration."
|
||||
},
|
||||
"featured": {
|
||||
"imageAlt": "Toju main application screenshot",
|
||||
"label": "Featured",
|
||||
"title": "The full Toju workspace",
|
||||
"description": "See the main interface where rooms, messages, presence, and media all come together in one focused layout."
|
||||
},
|
||||
"items": {
|
||||
"mainChatView": {
|
||||
"title": "Main chat view",
|
||||
"description": "The core Toju experience with channels, messages, and direct communication tools."
|
||||
},
|
||||
"gamingScreenShare": {
|
||||
"title": "Gaming screen share",
|
||||
"description": "Share gameplay, guides, and live moments with smooth full-resolution screen sharing."
|
||||
},
|
||||
"serverOverview": {
|
||||
"title": "Server overview",
|
||||
"description": "Navigate servers and rooms with a layout designed for clarity and speed."
|
||||
},
|
||||
"musicAndVoice": {
|
||||
"title": "Music and voice",
|
||||
"description": "Stay in sync with voice and media features in a focused, low-friction interface."
|
||||
},
|
||||
"videoSharing": {
|
||||
"title": "Video sharing",
|
||||
"description": "Preview and share visual content directly with your friends and communities."
|
||||
},
|
||||
"fileTransfers": {
|
||||
"title": "File transfers",
|
||||
"description": "Move files quickly without artificial size limits or unnecessary hoops."
|
||||
},
|
||||
"richMediaChat": {
|
||||
"title": "Rich media chat",
|
||||
"description": "Conversations stay lively with visual media support built right in."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Want to see it in action?",
|
||||
"description": "Download Toju or jump into the browser experience and explore the interface yourself."
|
||||
}
|
||||
},
|
||||
"philosophy": {
|
||||
"seo": {
|
||||
"title": "Our Philosophy - Why We Build Toju",
|
||||
"description": "Toju exists because privacy is a right, not a premium feature. No paywalls, no data harvesting, no predatory pricing. Learn why we build free, open-source communication tools."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Our Manifesto",
|
||||
"titlePrefix": "Why we",
|
||||
"titleHighlight": "build",
|
||||
"description": "A letter from the people behind the project."
|
||||
},
|
||||
"sections": {
|
||||
"ownership": {
|
||||
"title": "We Lost Something Important",
|
||||
"paragraph1": "Over the past two decades, something fundamental shifted. Our conversations, our memories, our connections - they stopped belonging to us. They live on servers we don't control, inside apps that treat our personal lives as data to be harvested, analyzed, and sold to the highest bidder.",
|
||||
"paragraph2": "We gave up ownership of our digital lives so gradually that most of us didn't even notice. A \"free\" app here, a convenient service there - each one taking a little more of our privacy in exchange for convenience. Toju exists because we believe it's time to take it back."
|
||||
},
|
||||
"paywalls": {
|
||||
"title": "No Paywalls. No Premium Tiers. Ever.",
|
||||
"paragraph1": "You know the playbook: launch a free product, build a user base, then start locking features behind subscription tiers. Can't share your screen at more than 720p unless you upgrade. File size limited to 8 MB. Want noise suppression? That's a premium feature now.",
|
||||
"paragraph2": "We refuse to play that game. <strong class=\"text-foreground\">Every feature in Toju is available to every user, always.</strong> There is no \"Toju Nitro,\" no \"Pro plan,\" no artificial limitations designed to push you toward your wallet. Communication is a human need, not a luxury - and the tools for it should reflect that."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy Is a Right, Not a Feature",
|
||||
"paragraph1": "Most communication platforms collect everything: who you talk to, when, for how long, what you share. They build profiles of your social graph, your habits, your interests. Even services that claim to care about privacy still store metadata on their servers.",
|
||||
"paragraph2": "Toju is architecturally different. Your data goes directly from your device to your friend's device. We don't have your messages. We don't have your files. We don't have your call history. Not because we promised not to look - but because the data never touches our infrastructure. We built the technology so that <strong class=\"text-foreground\">privacy isn't something we offer; it's something we literally cannot violate.</strong>"
|
||||
},
|
||||
"heart": {
|
||||
"title": "Built from the Heart",
|
||||
"paragraph1": "Toju wasn't born in a boardroom with revenue projections. It was born from frustration - frustration with being the product, with watching friends get locked out of features they used to have for free, with the growing feeling that the tools we depend on daily don't actually serve our interests.",
|
||||
"paragraph2": "We build Toju because we genuinely want to make the world a little better. The internet was supposed to connect people freely, and somewhere along the way, that mission got hijacked by business models that exploit the very connections they facilitate. <strong class=\"text-foreground\">Toju is our small act of reclaiming that original promise.</strong>"
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Transparent by Default",
|
||||
"paragraph1": "Every line of Toju's code is publicly available. You can read it, audit it, contribute to it, or fork it and build your own version. This isn't a marketing decision - it's an accountability decision. When you can see exactly how the software works, you never have to take our word for anything.",
|
||||
"paragraph2": "Open source also means Toju belongs to its community, not to a company. Even if we stopped development tomorrow, the project lives on. Your communication infrastructure shouldn't depend on a single organization's survival."
|
||||
}
|
||||
},
|
||||
"promise": {
|
||||
"title": "Our Promise",
|
||||
"items": {
|
||||
"noPaywalls": "We will <strong class=\"text-foreground\">never</strong> lock features behind a paywall.",
|
||||
"noDataSales": "We will <strong class=\"text-foreground\">never</strong> sell, monetize, or harvest your data.",
|
||||
"openSource": "We will <strong class=\"text-foreground\">always</strong> keep the source code open and auditable.",
|
||||
"usersBeforeProfit": "We will <strong class=\"text-foreground\">always</strong> put users before profit."
|
||||
},
|
||||
"signature": "- The Myxelium team"
|
||||
},
|
||||
"support": {
|
||||
"title": "Help us keep going",
|
||||
"description": "If Toju's mission resonates with you, consider supporting the project. Every contribution helps us keep the lights on and development moving forward - without ever compromising our values."
|
||||
}
|
||||
},
|
||||
"whatIsToju": {
|
||||
"seo": {
|
||||
"title": "What is Toju? - How It Works",
|
||||
"description": "Learn how Toju's peer-to-peer technology delivers free, private, unlimited voice calls, screen sharing, and file transfers without centralized servers."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "The Big Picture",
|
||||
"titlePrefix": "What is",
|
||||
"description": "Toju is a communication app that lets you voice chat, share your screen, send files, and message your friends - all without your data passing through someone else's servers. Think of it as your own private phone line that nobody can tap into."
|
||||
},
|
||||
"howItWorks": {
|
||||
"titlePrefix": "How does it",
|
||||
"titleHighlight": "work"
|
||||
},
|
||||
"steps": {
|
||||
"one": {
|
||||
"title": "You connect directly to your friends",
|
||||
"description": "When you start a call or send a file on Toju, your data travels directly from your device to your friend's device. There's no company server in the middle storing your conversations, listening to your calls, or scanning your files. This is called <strong class=\"text-foreground\">peer-to-peer</strong> - it's like having a direct road between your houses instead of going through a toll booth."
|
||||
},
|
||||
"two": {
|
||||
"title": "A tiny helper gets you connected",
|
||||
"description": "The only thing a server does is help your device find your friend's device - like a mutual friend introducing you at a party. Once you're connected, the server steps out of the picture entirely. It never sees what you say, share, or send. This helper is called a <strong class=\"text-foreground\">signal server</strong>, and you can even run your own if you'd like."
|
||||
},
|
||||
"three": {
|
||||
"title": "No limits because there are no middlemen",
|
||||
"description": "Since your data doesn't pass through our servers, we don't need to pay for massive infrastructure. That's why Toju can offer <strong class=\"text-foreground\">unlimited screen sharing, file transfers of any size, and high-quality voice</strong> - all completely free. There's no business reason to limit what you can do, and we never will."
|
||||
}
|
||||
},
|
||||
"whyDesigned": {
|
||||
"titlePrefix": "Why is it",
|
||||
"titleHighlight": "designed",
|
||||
"titleSuffix": "this way?"
|
||||
},
|
||||
"benefits": {
|
||||
"privacyArchitecture": {
|
||||
"title": "Privacy by Architecture",
|
||||
"description": "We didn't just add privacy as a feature - we built the entire app around it. When there's no central server handling your data, there's nothing to hack, subpoena, or sell. Your privacy isn't protected by a promise; it's protected by how the technology works."
|
||||
},
|
||||
"performance": {
|
||||
"title": "Performance Without Compromise",
|
||||
"description": "Direct connections mean lower latency. Your voice reaches your friend faster. Your screen share is smoother. Your file arrives in the time it actually takes to transfer - not in the time it takes to upload, store, then download."
|
||||
},
|
||||
"sustainable": {
|
||||
"title": "Sustainable & Free",
|
||||
"description": "Running a traditional chat service with millions of users costs enormous amounts of money for servers. That cost gets passed to you. With peer-to-peer, the only infrastructure we run is a tiny coordination server - costing almost nothing. That's how we keep it free permanently."
|
||||
},
|
||||
"independence": {
|
||||
"title": "Independence & Freedom",
|
||||
"description": "You're not locked into our ecosystem. The code is open source. You can run your own server. If we ever disappeared tomorrow, you could still use Toju. Your communication tools should belong to you, not a corporation."
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"titlePrefix": "Common",
|
||||
"titleHighlight": "Questions",
|
||||
"items": {
|
||||
"free": {
|
||||
"question": "Is Toju really free? What's the catch?",
|
||||
"answer": "Yes, it's really free. There is no catch. Because Toju uses peer-to-peer connections, we don't need expensive server infrastructure. Our costs are minimal, and we fund development through community support and donations. Every feature is available to everyone."
|
||||
},
|
||||
"technical": {
|
||||
"question": "Do I need technical knowledge to use Toju?",
|
||||
"answer": "Not at all. Toju works like any other chat app - download it, create an account, and start talking. All the peer-to-peer magic happens behind the scenes. You just enjoy the benefits of better privacy, performance, and no limits."
|
||||
},
|
||||
"selfHost": {
|
||||
"question": "What does \"self-host the signal server\" mean?",
|
||||
"answer": "The signal server is a tiny program that helps users find each other online. We run one by default, but if you prefer complete control, you can run your own copy on your own hardware. It's like having your own private phone directory - only people you invite can use it."
|
||||
},
|
||||
"safe": {
|
||||
"question": "Is my data safe?",
|
||||
"answer": "Your conversations, files, and calls go directly between you and the person you're talking to. They never pass through or get stored on our servers. Even if someone broke into our server, there would be nothing to find - because we never had your data in the first place."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to try it?",
|
||||
"description": "Available on Windows, Linux, and in your browser. Always free."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { HeaderComponent } from './components/header/header.component';
|
||||
import { FooterComponent } from './components/footer/footer.component';
|
||||
import { ParticleBgComponent } from './components/particle-bg/particle-bg.component';
|
||||
import translationsEn from '../../public/i18n/en.json';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -16,5 +18,13 @@ import { ParticleBgComponent } from './components/particle-bg/particle-bg.compon
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {}
|
||||
export class AppComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
constructor() {
|
||||
this.translate.setTranslation('en', translationsEn);
|
||||
this.translate.setFallbackLang('en');
|
||||
this.translate.use('en');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
@@ -13,6 +14,10 @@ export const appConfig: ApplicationConfig = {
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })
|
||||
),
|
||||
provideClientHydration(withEventReplay()),
|
||||
provideHttpClient(withFetch())
|
||||
provideHttpClient(withFetch()),
|
||||
provideTranslateService({
|
||||
fallbackLang: 'en',
|
||||
lang: 'en'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-border/50 bg-card/30 min-h-[90px] flex items-center justify-center text-xs text-muted-foreground/50"
|
||||
>
|
||||
Advertisement
|
||||
{{ 'components.adSlot.label' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AdService } from '../../services/ad.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ad-slot',
|
||||
standalone: true,
|
||||
imports: [TranslateModule],
|
||||
templateUrl: './ad-slot.component.html'
|
||||
})
|
||||
export class AdSlotComponent {
|
||||
|
||||
@@ -6,25 +6,25 @@
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<img
|
||||
src="/images/toju-logo-transparent.png"
|
||||
alt="Toju"
|
||||
[attr.alt]="'common.brand' | translate"
|
||||
class="h-8 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
Free, open-source, peer-to-peer communication. Built by people who believe privacy is a right, not a premium feature.
|
||||
{{ 'components.footer.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Product</h4>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.product' | translate }}</h4>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Downloads
|
||||
{{ 'components.footer.links.downloads' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -34,7 +34,7 @@
|
||||
rel="noopener"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Web Version
|
||||
{{ 'components.footer.links.webVersion' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -42,7 +42,7 @@
|
||||
routerLink="/what-is-toju"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
What is Toju?
|
||||
{{ 'components.footer.links.whatIsToju' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -50,7 +50,7 @@
|
||||
routerLink="/gallery"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Image Gallery
|
||||
{{ 'components.footer.links.imageGallery' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<!-- Community -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Community</h4>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.community' | translate }}</h4>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a
|
||||
@@ -74,7 +74,7 @@
|
||||
height="16"
|
||||
class="w-4 h-4 object-contain"
|
||||
/>
|
||||
Source Code
|
||||
{{ 'components.footer.links.sourceCode' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -91,7 +91,7 @@
|
||||
height="16"
|
||||
class="w-4 h-4 object-contain"
|
||||
/>
|
||||
GitHub
|
||||
{{ 'components.footer.links.github' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -108,7 +108,7 @@
|
||||
height="16"
|
||||
class="w-4 h-4 object-contain"
|
||||
/>
|
||||
Support Us
|
||||
{{ 'components.footer.links.supportUs' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -116,30 +116,30 @@
|
||||
|
||||
<!-- Values -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Values</h4>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.values' | translate }}</h4>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a
|
||||
routerLink="/philosophy"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Our Philosophy
|
||||
{{ 'components.footer.links.ourPhilosophy' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li><span class="text-sm text-muted-foreground">100% Free Forever</span></li>
|
||||
<li><span class="text-sm text-muted-foreground">Open Source</span></li>
|
||||
<li><span class="text-sm text-muted-foreground">{{ 'components.footer.values.freeForever' | translate }}</span></li>
|
||||
<li><span class="text-sm text-muted-foreground">{{ 'components.footer.values.openSource' | translate }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 pt-8 border-t border-border/30 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p class="text-xs text-muted-foreground">© {{ currentYear }} Myxelium. Toju is open-source software.</p>
|
||||
<p class="text-xs text-muted-foreground">{{ 'components.footer.copyright' | translate:{ year: currentYear } }}</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="https://git.azaaxin.com/myxelium/Toju"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="View source code on Gitea"
|
||||
[attr.aria-label]="'components.footer.viewSourceOnGitea' | translate"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<img
|
||||
@@ -154,7 +154,7 @@
|
||||
href="https://github.com/Myxelium"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="View the project on GitHub"
|
||||
[attr.aria-label]="'components.footer.viewProjectOnGitHub' | translate"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
imports: [RouterLink, TranslateModule],
|
||||
templateUrl: './footer.component.html'
|
||||
})
|
||||
export class FooterComponent {
|
||||
|
||||
@@ -6,21 +6,21 @@
|
||||
<!-- Logo -->
|
||||
<a
|
||||
routerLink="/"
|
||||
aria-label="Toju home"
|
||||
[attr.aria-label]="'components.header.homeAriaLabel' | translate"
|
||||
class="flex items-center group"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<img
|
||||
src="/images/toju-logo-transparent.png"
|
||||
alt="Toju"
|
||||
[attr.alt]="'common.brand' | translate"
|
||||
class="h-9 w-auto object-contain drop-shadow-lg group-hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<span class="text-xl font-bold text-foreground">Toju</span>
|
||||
<span class="text-xl font-bold text-foreground">{{ 'common.brand' | translate }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-400 border border-purple-500/30 uppercase tracking-wider"
|
||||
>
|
||||
Beta
|
||||
{{ 'components.header.beta' | translate }}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -32,28 +32,28 @@
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Home
|
||||
{{ 'components.header.navigation.home' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/what-is-toju"
|
||||
routerLinkActive="text-primary"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
What is Toju?
|
||||
{{ 'components.header.navigation.whatIsToju' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
routerLinkActive="text-primary"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Downloads
|
||||
{{ 'components.header.navigation.downloads' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/philosophy"
|
||||
routerLinkActive="text-primary"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Our Philosophy
|
||||
{{ 'components.header.navigation.philosophy' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
height="16"
|
||||
class="w-4 h-4 object-contain"
|
||||
/>
|
||||
Support Us
|
||||
{{ 'components.header.supportUs' | translate }}
|
||||
</a>
|
||||
<a
|
||||
href="https://web.toju.app/"
|
||||
@@ -80,7 +80,7 @@
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40"
|
||||
>
|
||||
Use Web Version
|
||||
{{ 'components.header.useWebVersion' | translate }}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
@@ -102,7 +102,7 @@
|
||||
type="button"
|
||||
class="md:hidden text-foreground p-2"
|
||||
(click)="mobileOpen.set(!mobileOpen())"
|
||||
aria-label="Toggle menu"
|
||||
[attr.aria-label]="'components.header.toggleMenu' | translate"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
@@ -138,22 +138,22 @@
|
||||
<a
|
||||
routerLink="/"
|
||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>Home</a
|
||||
>{{ 'components.header.navigation.home' | translate }}</a
|
||||
>
|
||||
<a
|
||||
routerLink="/what-is-toju"
|
||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>What is Toju?</a
|
||||
>{{ 'components.header.navigation.whatIsToju' | translate }}</a
|
||||
>
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>Downloads</a
|
||||
>{{ 'components.header.navigation.downloads' | translate }}</a
|
||||
>
|
||||
<a
|
||||
routerLink="/philosophy"
|
||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>Our Philosophy</a
|
||||
>{{ 'components.header.navigation.philosophy' | translate }}</a
|
||||
>
|
||||
<hr class="border-border/30" />
|
||||
<a
|
||||
@@ -169,7 +169,7 @@
|
||||
height="16"
|
||||
class="w-4 h-4 object-contain"
|
||||
/>
|
||||
Support Us
|
||||
{{ 'components.header.supportUs' | translate }}
|
||||
</a>
|
||||
<a
|
||||
href="https://web.toju.app/"
|
||||
@@ -177,7 +177,7 @@
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium"
|
||||
>
|
||||
Use Web Version
|
||||
{{ 'components.header.useWebVersion' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -5,13 +5,18 @@ import {
|
||||
HostListener,
|
||||
PLATFORM_ID
|
||||
} from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive],
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
TranslateModule
|
||||
],
|
||||
templateUrl: './header.component.html'
|
||||
})
|
||||
export class HeaderComponent {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="min-h-screen pt-32 pb-20">
|
||||
<section class="container mx-auto px-6 mb-16">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">Download <span class="gradient-text">Toju</span></h1>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.downloads.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span></h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||
Available for Windows, Linux, and in your browser. Always free, always the full experience.
|
||||
{{ 'pages.downloads.hero.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -18,10 +18,10 @@
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-4"
|
||||
>
|
||||
Recommended for you
|
||||
{{ 'pages.downloads.recommended.badge' | translate }}
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-2">Toju for {{ detectedOS().name }}</h2>
|
||||
<p class="text-muted-foreground mb-6">Version {{ latestRelease()!.tag_name }}</p>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-2">{{ 'pages.downloads.recommended.title' | translate:{ os: getDetectedOsLabel() } }}</h2>
|
||||
<p class="text-muted-foreground mb-6">{{ 'pages.downloads.recommended.version' | translate:{ version: latestRelease()!.tag_name } }}</p>
|
||||
|
||||
@if (recommendedUrl()) {
|
||||
<a
|
||||
@@ -41,21 +41,21 @@
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Download for {{ detectedOS().name }}
|
||||
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
|
||||
</a>
|
||||
}
|
||||
|
||||
<p class="text-xs text-muted-foreground/60 mt-4">
|
||||
Or
|
||||
{{ 'pages.downloads.recommended.webVersionPrefix' | translate }}
|
||||
<a
|
||||
href="https://web.toju.app/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
use the web version
|
||||
{{ 'pages.downloads.recommended.webVersionLink' | translate }}
|
||||
</a>
|
||||
- no download required.
|
||||
{{ 'pages.downloads.recommended.webVersionSuffix' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@
|
||||
<section class="container mx-auto px-6 mb-20">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade">
|
||||
All platforms <span class="text-muted-foreground font-normal text-lg">- {{ release.tag_name }}</span>
|
||||
{{ 'pages.downloads.allPlatforms.title' | translate }} <span class="text-muted-foreground font-normal text-lg">- {{ release.tag_name }}</span>
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-3 section-fade">
|
||||
@@ -84,7 +84,7 @@
|
||||
@if (getOsIcon(asset.name)) {
|
||||
<img
|
||||
[src]="getOsIcon(asset.name)"
|
||||
[alt]="releaseService.getAssetOS(asset.name) + ' icon'"
|
||||
[attr.alt]="'pages.downloads.allPlatforms.assetIconAlt' | translate:{ os: getAssetOSLabel(asset.name) }"
|
||||
width="32"
|
||||
height="32"
|
||||
class="w-8 h-8 object-contain invert"
|
||||
@@ -94,7 +94,7 @@
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground group-hover:text-purple-400 transition-colors">{{ asset.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ releaseService.getAssetOS(asset.name) }} · {{ releaseService.formatBytes(asset.size) }}
|
||||
{{ getAssetOSLabel(asset.name) }} · {{ releaseService.formatBytes(asset.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@
|
||||
@if (releases().length > 1) {
|
||||
<section class="container mx-auto px-6 mb-20">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade">Previous Releases</h2>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade">{{ 'pages.downloads.previousReleases.title' | translate }}</h2>
|
||||
|
||||
<div class="space-y-4 section-fade">
|
||||
@for (release of releases().slice(1); track release.tag_name) {
|
||||
@@ -147,7 +147,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ release.name || release.tag_name }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatDate(release.published_at) }} · {{ release.assets.length }} files</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatDate(release.published_at) }} · {{ 'pages.downloads.previousReleases.fileCount' | translate:{ count: release.assets.length } }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
@@ -178,7 +178,7 @@
|
||||
@if (getOsIcon(asset.name)) {
|
||||
<img
|
||||
[src]="getOsIcon(asset.name)"
|
||||
[alt]="releaseService.getAssetOS(asset.name) + ' icon'"
|
||||
[attr.alt]="'pages.downloads.allPlatforms.assetIconAlt' | translate:{ os: getAssetOSLabel(asset.name) }"
|
||||
width="16"
|
||||
height="16"
|
||||
class="w-4 h-4 object-contain mr-1 invert"
|
||||
@@ -236,7 +236,7 @@
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
Fetching releases...
|
||||
{{ 'pages.downloads.loading' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -254,14 +254,14 @@
|
||||
d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20 5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"
|
||||
/>
|
||||
</svg>
|
||||
Stay updated with our
|
||||
{{ 'pages.downloads.rss.prefix' | translate }}
|
||||
<a
|
||||
href="https://git.azaaxin.com/myxelium/Toju/releases.rss"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
RSS feed
|
||||
{{ 'pages.downloads.rss.link' | translate }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
ReleaseService,
|
||||
Release,
|
||||
@@ -21,7 +22,7 @@ import { getOsIconPath } from './os-icon.util';
|
||||
@Component({
|
||||
selector: 'app-downloads',
|
||||
standalone: true,
|
||||
imports: [AdSlotComponent],
|
||||
imports: [AdSlotComponent, TranslateModule],
|
||||
templateUrl: './downloads.component.html'
|
||||
})
|
||||
export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@@ -29,7 +30,7 @@ export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly releases = signal<Release[]>([]);
|
||||
readonly latestRelease = signal<Release | null>(null);
|
||||
readonly detectedOS = signal<DetectedOS>({
|
||||
name: 'Linux',
|
||||
key: 'linux',
|
||||
icon: '🐧',
|
||||
filePattern: /\.AppImage$/i,
|
||||
ymlFile: 'latest-linux.yml'
|
||||
@@ -40,11 +41,10 @@ export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly seoService = inject(SeoService);
|
||||
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.update({
|
||||
title: 'Download Toju',
|
||||
description: 'Download Toju for Windows, Linux, or use the web version. Free peer-to-peer voice chat, screen sharing, and file transfers.',
|
||||
this.seoService.updateFromTranslations('pages.downloads.seo', {
|
||||
url: 'https://toju.app/downloads'
|
||||
});
|
||||
|
||||
@@ -89,6 +89,14 @@ export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return getOsIconPath(name, size);
|
||||
}
|
||||
|
||||
getDetectedOsLabel(): string {
|
||||
return this.translate.instant(`common.os.${this.detectedOS().key}`);
|
||||
}
|
||||
|
||||
getAssetOSLabel(name: string): string {
|
||||
return this.translate.instant(`common.os.${this.releaseService.getAssetOSKey(name)}`);
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
|
||||
>
|
||||
Image Gallery
|
||||
{{ 'pages.gallery.hero.badge' | translate }}
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">A closer look at <span class="gradient-text">Toju</span></h1>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.gallery.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span></h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||
Explore screenshots of the app experience, from voice chat and media sharing to servers, rooms, and full-screen collaboration.
|
||||
{{ 'pages.gallery.hero.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="relative aspect-[16/9]">
|
||||
<img
|
||||
ngSrc="/images/screenshots/screenshot_main.png"
|
||||
alt="Toju main application screenshot"
|
||||
[attr.alt]="'pages.gallery.featured.imageAlt' | translate"
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1536px) 75vw, (min-width: 1280px) 90vw, 100vw"
|
||||
@@ -27,10 +27,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-background via-background/80 to-transparent p-6 md:p-8">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-purple-400 mb-2">Featured</p>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-2">The full Toju workspace</h2>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-purple-400 mb-2">{{ 'pages.gallery.featured.label' | translate }}</p>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-2">{{ 'pages.gallery.featured.title' | translate }}</h2>
|
||||
<p class="max-w-2xl text-sm md:text-base text-muted-foreground">
|
||||
See the main interface where rooms, messages, presence, and media all come together in one focused layout.
|
||||
{{ 'pages.gallery.featured.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,15 +52,15 @@
|
||||
<div class="relative aspect-video overflow-hidden">
|
||||
<img
|
||||
[ngSrc]="item.src"
|
||||
[alt]="item.title"
|
||||
[attr.alt]="item.titleKey | translate"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
|
||||
class="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">{{ item.title }}</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">{{ item.description }}</p>
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">{{ item.titleKey | translate }}</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">{{ item.descriptionKey | translate }}</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
@@ -72,14 +72,14 @@
|
||||
<div
|
||||
class="max-w-4xl mx-auto section-fade rounded-3xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10 text-center"
|
||||
>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Want to see it in action?</h2>
|
||||
<p class="text-muted-foreground leading-relaxed mb-6">Download Toju or jump into the browser experience and explore the interface yourself.</p>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">{{ 'pages.gallery.cta.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed mb-6">{{ 'pages.gallery.cta.description' | translate }}</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25"
|
||||
>
|
||||
Go to downloads
|
||||
{{ 'common.actions.goToDownloads' | translate }}
|
||||
</a>
|
||||
<a
|
||||
href="https://web.toju.app/"
|
||||
@@ -87,7 +87,7 @@
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||
>
|
||||
Open web version
|
||||
{{ 'common.actions.openWebVersion' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||
@@ -14,8 +15,8 @@ import { SeoService } from '../../services/seo.service';
|
||||
|
||||
interface GalleryItem {
|
||||
src: string;
|
||||
title: string;
|
||||
description: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -23,6 +24,7 @@ interface GalleryItem {
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgOptimizedImage,
|
||||
TranslateModule,
|
||||
RouterLink,
|
||||
AdSlotComponent
|
||||
],
|
||||
@@ -32,38 +34,38 @@ export class GalleryComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly galleryItems: GalleryItem[] = [
|
||||
{
|
||||
src: '/images/screenshots/screenshot_main.png',
|
||||
title: 'Main chat view',
|
||||
description: 'The core Toju experience with channels, messages, and direct communication tools.'
|
||||
titleKey: 'pages.gallery.items.mainChatView.title',
|
||||
descriptionKey: 'pages.gallery.items.mainChatView.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/screenshare_gaming.png',
|
||||
title: 'Gaming screen share',
|
||||
description: 'Share gameplay, guides, and live moments with smooth full-resolution screen sharing.'
|
||||
titleKey: 'pages.gallery.items.gamingScreenShare.title',
|
||||
descriptionKey: 'pages.gallery.items.gamingScreenShare.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/serverViewScreen.png',
|
||||
title: 'Server overview',
|
||||
description: 'Navigate servers and rooms with a layout designed for clarity and speed.'
|
||||
titleKey: 'pages.gallery.items.serverOverview.title',
|
||||
descriptionKey: 'pages.gallery.items.serverOverview.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/music.png',
|
||||
title: 'Music and voice',
|
||||
description: 'Stay in sync with voice and media features in a focused, low-friction interface.'
|
||||
titleKey: 'pages.gallery.items.musicAndVoice.title',
|
||||
descriptionKey: 'pages.gallery.items.musicAndVoice.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/videos.png',
|
||||
title: 'Video sharing',
|
||||
description: 'Preview and share visual content directly with your friends and communities.'
|
||||
titleKey: 'pages.gallery.items.videoSharing.title',
|
||||
descriptionKey: 'pages.gallery.items.videoSharing.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/filedownload.png',
|
||||
title: 'File transfers',
|
||||
description: 'Move files quickly without artificial size limits or unnecessary hoops.'
|
||||
titleKey: 'pages.gallery.items.fileTransfers.title',
|
||||
descriptionKey: 'pages.gallery.items.fileTransfers.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/gif.png',
|
||||
title: 'Rich media chat',
|
||||
description: 'Conversations stay lively with visual media support built right in.'
|
||||
titleKey: 'pages.gallery.items.richMediaChat.title',
|
||||
descriptionKey: 'pages.gallery.items.richMediaChat.description'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -72,9 +74,7 @@ export class GalleryComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.update({
|
||||
title: 'Toju Image Gallery',
|
||||
description: 'Browse screenshots of Toju and explore the interface for chat, file sharing, voice, and screen sharing.',
|
||||
this.seoService.updateFromTranslations('pages.gallery.seo', {
|
||||
url: 'https://toju.app/gallery'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,19 +16,19 @@
|
||||
class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-purple-500/30 bg-purple-500/10 text-purple-400 text-sm font-medium mb-8 animate-fade-in"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span>
|
||||
Currently in Beta - Free & Open Source
|
||||
{{ 'pages.home.hero.badge' | translate }}
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl md:text-7xl lg:text-8xl font-extrabold tracking-tight mb-6 animate-fade-in-up">
|
||||
<span class="text-foreground">Talk freely.</span><br />
|
||||
<span class="gradient-text">Own your voice.</span>
|
||||
<span class="text-foreground">{{ 'pages.home.hero.titleLine1' | translate }}</span><br />
|
||||
<span class="gradient-text">{{ 'pages.home.hero.titleLine2' | translate }}</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-10 leading-relaxed animate-fade-in-up"
|
||||
style="animation-delay: 0.2s"
|
||||
>
|
||||
Crystal-clear voice calls, unlimited screen sharing, and file transfers with no size limits. Peer-to-peer. Private. Completely free.
|
||||
{{ 'pages.home.hero.description' | translate }}
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
@@ -54,7 +54,7 @@
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Download for {{ detectedOS().name }}
|
||||
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
|
||||
<span class="text-sm opacity-75">{{ detectedOS().icon }}</span>
|
||||
</a>
|
||||
} @else {
|
||||
@@ -75,7 +75,7 @@
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Download Toju
|
||||
{{ 'common.actions.downloadBrand' | translate }}
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium text-lg hover:bg-card hover:border-purple-500/30 transition-all backdrop-blur-sm"
|
||||
>
|
||||
Open in Browser
|
||||
{{ 'common.actions.openInBrowser' | translate }}
|
||||
<svg
|
||||
class="w-5 h-5 opacity-60"
|
||||
fill="none"
|
||||
@@ -107,11 +107,11 @@
|
||||
class="text-xs text-muted-foreground/60 animate-fade-in"
|
||||
style="animation-delay: 0.6s"
|
||||
>
|
||||
Version {{ latestVersion() }} ·
|
||||
{{ 'pages.home.hero.version' | translate:{ version: latestVersion() } }} ·
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="underline hover:text-muted-foreground transition-colors"
|
||||
>All platforms</a
|
||||
>{{ 'pages.home.hero.allPlatforms' | translate }}</a
|
||||
>
|
||||
</p>
|
||||
}
|
||||
@@ -142,10 +142,10 @@
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="text-center mb-20 section-fade">
|
||||
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-4">
|
||||
Everything you need,<br />
|
||||
<span class="gradient-text">nothing you don't.</span>
|
||||
{{ 'pages.home.features.titleLine1' | translate }}<br />
|
||||
<span class="gradient-text">{{ 'pages.home.features.titleLine2' | translate }}</span>
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg max-w-xl mx-auto">No bloat. No paywalls. Just the tools to connect with the people who matter.</p>
|
||||
<p class="text-muted-foreground text-lg max-w-xl mx-auto">{{ 'pages.home.features.description' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -168,9 +168,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">HD Voice Calls</h3>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.voiceCalls.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Crystal-clear audio with built-in noise reduction. Hear every word, not the background. No quality compromises, ever.
|
||||
{{ 'pages.home.features.items.voiceCalls.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -194,10 +194,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">Screen Sharing</h3>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.screenSharing.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Share your screen at full resolution. No time limits, no quality downgrades. Perfect for pair programming, presentations, or showing your
|
||||
epic gameplay.
|
||||
{{ 'pages.home.features.items.screenSharing.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -221,9 +220,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">Unlimited File Sharing</h3>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.fileSharing.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Send files of any size directly to your friends. No upload limits, no compression. Your files go straight from you to them.
|
||||
{{ 'pages.home.features.items.fileSharing.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -246,10 +245,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">True Privacy</h3>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.privacy.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Peer-to-peer means your data goes directly between you and your friends. No servers storing your conversations. Your business is your
|
||||
business.
|
||||
{{ 'pages.home.features.items.privacy.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -273,9 +271,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">Open Source</h3>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.openSource.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Every line of code is public. Audit it, modify it, host your own signal server. Full transparency - nothing is hidden.
|
||||
{{ 'pages.home.features.items.openSource.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -299,9 +297,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">Completely Free</h3>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.free.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
No premium tiers. No paywalls. No "starter plans". Every feature is available to everyone, always. Made with love, not profit margins.
|
||||
{{ 'pages.home.features.items.free.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -338,15 +336,14 @@
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Built for Gamers
|
||||
{{ 'pages.home.gaming.badge' | translate }}
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||
Your perfect<br />
|
||||
<span class="gradient-text">gaming companion.</span>
|
||||
{{ 'pages.home.gaming.titleLine1' | translate }}<br />
|
||||
<span class="gradient-text">{{ 'pages.home.gaming.titleLine2' | translate }}</span>
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
|
||||
Ultra-low latency voice chat that doesn't eat your bandwidth. Share your screen without frame drops. Send clips and files instantly. All
|
||||
while keeping your CPU free for what matters - winning.
|
||||
{{ 'pages.home.gaming.description' | translate }}
|
||||
</p>
|
||||
<ul class="space-y-4">
|
||||
<li class="flex items-center gap-3 text-muted-foreground">
|
||||
@@ -363,7 +360,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Low-latency peer-to-peer voice - no relay servers in the way</span>
|
||||
<span>{{ 'pages.home.gaming.bullets.lowLatency' | translate }}</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-muted-foreground">
|
||||
<svg
|
||||
@@ -379,7 +376,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>AI-powered noise suppression - keyboard clatter stays out</span>
|
||||
<span>{{ 'pages.home.gaming.bullets.noiseSuppression' | translate }}</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-muted-foreground">
|
||||
<svg
|
||||
@@ -395,7 +392,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Full-resolution screen sharing at high FPS</span>
|
||||
<span>{{ 'pages.home.gaming.bullets.screenShare' | translate }}</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-muted-foreground">
|
||||
<svg
|
||||
@@ -411,7 +408,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Send replays and screenshots with no file size limit</span>
|
||||
<span>{{ 'pages.home.gaming.bullets.fileTransfers' | translate }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -422,11 +419,11 @@
|
||||
ngSrc="/images/screenshots/screenshare_gaming.png"
|
||||
fill
|
||||
priority
|
||||
alt="Toju gaming screen sharing preview"
|
||||
[attr.alt]="'pages.home.gaming.imageAlt' | translate"
|
||||
class="object-cover"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent"></div>
|
||||
<div class="absolute bottom-4 left-4 text-sm text-muted-foreground/60">Game on. No limits.</div>
|
||||
<div class="absolute bottom-4 left-4 text-sm text-muted-foreground/60">{{ 'pages.home.gaming.caption' | translate }}</div>
|
||||
</div>
|
||||
<!-- Glow effect -->
|
||||
<div class="absolute -inset-4 bg-purple-600/5 rounded-3xl blur-2xl -z-10"></div>
|
||||
@@ -457,22 +454,21 @@
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"
|
||||
/>
|
||||
</svg>
|
||||
Self-Hostable
|
||||
{{ 'pages.home.selfHostable.badge' | translate }}
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||
Your infrastructure,<br />
|
||||
<span class="gradient-text">your rules.</span>
|
||||
{{ 'pages.home.selfHostable.titleLine1' | translate }}<br />
|
||||
<span class="gradient-text">{{ 'pages.home.selfHostable.titleLine2' | translate }}</span>
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
|
||||
Toju uses a lightweight coordination server to help peers find each other - that's it. Your actual conversations never touch a server. Want
|
||||
even more control? Run your own coordination server in minutes. Full independence, zero compromises.
|
||||
{{ 'pages.home.selfHostable.description' | translate }}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a
|
||||
routerLink="/what-is-toju"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||
>
|
||||
Learn how it works
|
||||
{{ 'common.actions.learnHowItWorks' | translate }}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
@@ -500,7 +496,7 @@
|
||||
height="16"
|
||||
class="w-4 h-4 object-contain"
|
||||
/>
|
||||
View source code
|
||||
{{ 'common.actions.viewSourceCode' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,22 +507,22 @@
|
||||
<section class="relative py-24">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-purple-950/20 via-violet-950/30 to-purple-950/20"></div>
|
||||
<div class="relative container mx-auto px-6 text-center section-fade">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4">Ready to take back your conversations?</h2>
|
||||
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">Join thousands choosing privacy, freedom, and real connection.</p>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4">{{ 'pages.home.cta.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">{{ 'pages.home.cta.description' | translate }}</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
@if (downloadUrl()) {
|
||||
<a
|
||||
[href]="downloadUrl()"
|
||||
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
|
||||
>
|
||||
Download for {{ detectedOS().name }}
|
||||
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
|
||||
</a>
|
||||
} @else {
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
|
||||
>
|
||||
Download Toju
|
||||
{{ 'common.actions.downloadBrand' | translate }}
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
@@ -535,7 +531,7 @@
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||
>
|
||||
Try in Browser
|
||||
{{ 'common.actions.tryInBrowser' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ReleaseService, DetectedOS } from '../../services/release.service';
|
||||
import { SeoService } from '../../services/seo.service';
|
||||
@@ -20,6 +21,7 @@ import { ParallaxDirective } from '../../directives/parallax.directive';
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgOptimizedImage,
|
||||
TranslateModule,
|
||||
RouterLink,
|
||||
AdSlotComponent,
|
||||
ParallaxDirective
|
||||
@@ -28,7 +30,7 @@ import { ParallaxDirective } from '../../directives/parallax.directive';
|
||||
})
|
||||
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly detectedOS = signal<DetectedOS>({
|
||||
name: 'Linux',
|
||||
key: 'linux',
|
||||
icon: '🐧',
|
||||
filePattern: /\.AppImage$/i,
|
||||
ymlFile: 'latest-linux.yml'
|
||||
@@ -40,13 +42,10 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly seoService = inject(SeoService);
|
||||
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.update({
|
||||
title: 'Free Peer-to-Peer Voice, Video & Chat',
|
||||
description:
|
||||
'Toju is a free, open-source, peer-to-peer communication app. Crystal-clear voice calls, unlimited screen sharing, '
|
||||
+ 'no file size limits, complete privacy.',
|
||||
this.seoService.updateFromTranslations('pages.home.seo', {
|
||||
url: 'https://toju.app/'
|
||||
});
|
||||
|
||||
@@ -74,4 +73,8 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.scrollAnimation.destroy();
|
||||
}
|
||||
|
||||
getDetectedOsLabel(): string {
|
||||
return this.translate.instant(`common.os.${this.detectedOS().key}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
|
||||
>
|
||||
Our Manifesto
|
||||
{{ 'pages.philosophy.hero.badge' | translate }}
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">Why we <span class="gradient-text">build</span> Toju</h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">A letter from the people behind the project.</p>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.philosophy.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.philosophy.hero.titleHighlight' | translate }}</span> {{ 'common.brand' | translate }}</h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">{{ 'pages.philosophy.hero.description' | translate }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -19,81 +19,58 @@
|
||||
<article class="max-w-3xl mx-auto prose prose-invert prose-lg">
|
||||
<!-- Ownership -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">We Lost Something Important</h2>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">{{ 'pages.philosophy.sections.ownership.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Over the past two decades, something fundamental shifted. Our conversations, our memories, our connections - they stopped belonging to us.
|
||||
They live on servers we don't control, inside apps that treat our personal lives as data to be harvested, analyzed, and sold to the highest
|
||||
bidder.
|
||||
{{ 'pages.philosophy.sections.ownership.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
We gave up ownership of our digital lives so gradually that most of us didn't even notice. A "free" app here, a convenient service there -
|
||||
each one taking a little more of our privacy in exchange for convenience. Toju exists because we believe it's time to take it back.
|
||||
{{ 'pages.philosophy.sections.ownership.paragraph2' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No predatory pricing -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">No Paywalls. No Premium Tiers. Ever.</h2>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.paywalls.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
You know the playbook: launch a free product, build a user base, then start locking features behind subscription tiers. Can't share your
|
||||
screen at more than 720p unless you upgrade. File size limited to 8 MB. Want noise suppression? That's a premium feature now.
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
We refuse to play that game. <strong class="text-foreground">Every feature in Toju is available to every user, always.</strong>
|
||||
There is no "Toju Nitro," no "Pro plan," no artificial limitations designed to push you toward your wallet. Communication is a human need,
|
||||
not a luxury - and the tools for it should reflect that.
|
||||
{{ 'pages.philosophy.sections.paywalls.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.paywalls.paragraph2' | translate"></p>
|
||||
</div>
|
||||
|
||||
<app-ad-slot />
|
||||
|
||||
<!-- Privacy as a right -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Privacy Is a Right, Not a Feature</h2>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.privacy.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Most communication platforms collect everything: who you talk to, when, for how long, what you share. They build profiles of your social
|
||||
graph, your habits, your interests. Even services that claim to care about privacy still store metadata on their servers.
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Toju is architecturally different. Your data goes directly from your device to your friend's device. We don't have your messages. We don't
|
||||
have your files. We don't have your call history. Not because we promised not to look - but because the data never touches our
|
||||
infrastructure. We built the technology so that
|
||||
<strong class="text-foreground">privacy isn't something we offer; it's something we literally cannot violate.</strong>
|
||||
{{ 'pages.philosophy.sections.privacy.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.privacy.paragraph2' | translate"></p>
|
||||
</div>
|
||||
|
||||
<!-- Better world -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Built from the Heart</h2>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.heart.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Toju wasn't born in a boardroom with revenue projections. It was born from frustration - frustration with being the product, with watching
|
||||
friends get locked out of features they used to have for free, with the growing feeling that the tools we depend on daily don't actually
|
||||
serve our interests.
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
We build Toju because we genuinely want to make the world a little better. The internet was supposed to connect people freely, and somewhere
|
||||
along the way, that mission got hijacked by business models that exploit the very connections they facilitate.
|
||||
<strong class="text-foreground">Toju is our small act of reclaiming that original promise.</strong>
|
||||
{{ 'pages.philosophy.sections.heart.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.heart.paragraph2' | translate"></p>
|
||||
</div>
|
||||
|
||||
<!-- Open source -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Transparent by Default</h2>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.openSource.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Every line of Toju's code is publicly available. You can read it, audit it, contribute to it, or fork it and build your own version. This
|
||||
isn't a marketing decision - it's an accountability decision. When you can see exactly how the software works, you never have to take our
|
||||
word for anything.
|
||||
{{ 'pages.philosophy.sections.openSource.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
Open source also means Toju belongs to its community, not to a company. Even if we stopped development tomorrow, the project lives on. Your
|
||||
communication infrastructure shouldn't depend on a single organization's survival.
|
||||
{{ 'pages.philosophy.sections.openSource.paragraph2' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Commitment -->
|
||||
<div class="section-fade rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">Our Promise</h2>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">{{ 'pages.philosophy.promise.title' | translate }}</h2>
|
||||
<ul class="space-y-4 text-muted-foreground !list-none !pl-0">
|
||||
<li class="flex items-start gap-3">
|
||||
<svg
|
||||
@@ -109,7 +86,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>We will <strong class="text-foreground">never</strong> lock features behind a paywall.</span>
|
||||
<span [innerHTML]="'pages.philosophy.promise.items.noPaywalls' | translate"></span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg
|
||||
@@ -125,7 +102,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>We will <strong class="text-foreground">never</strong> sell, monetize, or harvest your data.</span>
|
||||
<span [innerHTML]="'pages.philosophy.promise.items.noDataSales' | translate"></span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg
|
||||
@@ -141,7 +118,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>We will <strong class="text-foreground">always</strong> keep the source code open and auditable.</span>
|
||||
<span [innerHTML]="'pages.philosophy.promise.items.openSource' | translate"></span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg
|
||||
@@ -157,10 +134,10 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>We will <strong class="text-foreground">always</strong> put users before profit.</span>
|
||||
<span [innerHTML]="'pages.philosophy.promise.items.usersBeforeProfit' | translate"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-muted-foreground mt-6 text-sm">- The Myxelium team</p>
|
||||
<p class="text-muted-foreground mt-6 text-sm">{{ 'pages.philosophy.promise.signature' | translate }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
@@ -168,10 +145,9 @@
|
||||
<!-- Support CTA -->
|
||||
<section class="container mx-auto px-6">
|
||||
<div class="section-fade max-w-2xl mx-auto text-center">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Help us keep going</h2>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">{{ 'pages.philosophy.support.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground mb-8 leading-relaxed">
|
||||
If Toju's mission resonates with you, consider supporting the project. Every contribution helps us keep the lights on and development moving
|
||||
forward - without ever compromising our values.
|
||||
{{ 'pages.philosophy.support.description' | translate }}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a
|
||||
@@ -187,13 +163,13 @@
|
||||
height="20"
|
||||
class="w-5 h-5 object-contain"
|
||||
/>
|
||||
Buy us a coffee
|
||||
{{ 'common.actions.buyUsCoffee' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||
>
|
||||
Download Toju
|
||||
{{ 'common.actions.downloadBrand' | translate }}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user