Compare commits
3 Commits
f8fd78d21a
...
1cdd1c5d2b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cdd1c5d2b | |||
| 141de64767 | |||
| eb987ac672 |
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,7 @@ import {
|
||||
restartToApplyUpdate,
|
||||
type DesktopUpdateServerContext
|
||||
} from '../update/desktop-updater';
|
||||
import { consumePendingDeepLink } from '../app/deep-links';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -258,6 +259,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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -115,6 +116,7 @@ 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';
|
||||
hardwareAcceleration: boolean;
|
||||
@@ -143,6 +145,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>;
|
||||
@@ -198,6 +201,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),
|
||||
@@ -216,6 +220,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),
|
||||
|
||||
@@ -120,6 +120,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());
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -97,12 +97,17 @@ async function bootstrap(): Promise<void> {
|
||||
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 ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||
|
||||
if (serverProtocol === 'https' && serverHost && !['localhost', '127.0.0.1', '::1'].includes(serverHost)) {
|
||||
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
|
||||
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
||||
}
|
||||
};
|
||||
|
||||
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,10 @@ 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 +16,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 +150,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 +200,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 +229,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 +285,7 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
this.attachmentsByMessage.delete(messageId);
|
||||
this.messageRoomIds.delete(messageId);
|
||||
this.clearMessageScopedState(messageId);
|
||||
|
||||
if (hadCachedAttachments) {
|
||||
@@ -276,8 +343,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 +380,7 @@ export class AttachmentService {
|
||||
|
||||
for (const attachment of newAttachments) {
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,9 +450,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 +512,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 +553,7 @@ export class AttachmentService {
|
||||
this.attachmentsByMessage.set(messageId, list);
|
||||
this.touch();
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
this.queueAutoDownloadsForMessage(messageId, attachment.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -772,6 +844,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 +971,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 +1277,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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,14 @@ 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 +30,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 +56,8 @@ type ElectronWindow = Window & {
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu })
|
||||
lucideMenu,
|
||||
lucideRefreshCw })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
@@ -78,12 +85,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 +139,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 +222,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();
|
||||
|
||||
@@ -60,6 +60,28 @@ export class VoicePlaybackService {
|
||||
: 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 +180,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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 @@ 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 +361,8 @@ 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 +375,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 +479,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 +500,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 +545,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 +557,58 @@ 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
|
||||
};
|
||||
|
||||
this.db.updateRoom(room.id, updatedSettings);
|
||||
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, 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 +802,11 @@ export class RoomsEffects {
|
||||
})
|
||||
);
|
||||
|
||||
return [UsersActions.clearUsers(), ...joinActions];
|
||||
return [
|
||||
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
||||
UsersActions.clearUsers(),
|
||||
...joinActions
|
||||
];
|
||||
}
|
||||
|
||||
case 'user_joined': {
|
||||
@@ -729,6 +822,7 @@ export class RoomsEffects {
|
||||
};
|
||||
|
||||
return [
|
||||
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
||||
})
|
||||
@@ -743,7 +837,20 @@ 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 +1052,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 +1118,7 @@ export class RoomsEffects {
|
||||
this.webrtc.sendToPeer(fromPeerId, {
|
||||
type: 'server-state-full',
|
||||
roomId: room.id,
|
||||
room,
|
||||
room: this.sanitizeRoomSnapshot(room),
|
||||
bans
|
||||
});
|
||||
}),
|
||||
@@ -1075,7 +1188,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 +1313,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);
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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();
|
||||
|
||||
@@ -44,11 +47,40 @@ function buildElectronArgs(argv) {
|
||||
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: process.env,
|
||||
env: buildChildEnv(process.env),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
@@ -58,6 +90,11 @@ function main() {
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (code === DEV_SINGLE_INSTANCE_EXIT_CODE) {
|
||||
keepProcessAliveForExistingInstance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user