Add eslint

This commit is contained in:
2026-03-03 22:56:12 +01:00
parent d641229f9d
commit ad0e28bf84
92 changed files with 2656 additions and 1127 deletions

218
eslint.config.js Normal file
View File

@@ -0,0 +1,218 @@
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const stylisticTs = require('@stylistic/eslint-plugin-ts');
const stylisticJs = require('@stylistic/eslint-plugin-js');
module.exports = tseslint.config(
{
ignores: [
'**/generated/*',
'dist/**',
'dist-electron/**',
'.angular/**',
'**/migrations/**',
'release/**',
'src/index.html',
'server/**'
]
},
{
files: ['src/app/core/services/**/*.ts'],
rules: {
'@typescript-eslint/member-ordering': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-invalid-void-type': 'off',
'@typescript-eslint/prefer-for-of': 'off',
'id-length': 'off',
'max-statements-per-line': 'off'
}
},
{
files: ['**/*.ts'],
plugins: {
'@stylistic/ts': stylisticTs,
'@stylistic/js': stylisticJs
},
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
...tseslint.configs.strict
],
processor: angular.processInlineTemplates,
rules: {
'@typescript-eslint/no-extraneous-class': 'off',
'@angular-eslint/component-class-suffix': ['error', { suffixes: ['Component', 'Page', 'Stub'] }],
'@angular-eslint/directive-class-suffix': 'error',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }],
'@typescript-eslint/array-type': ['error', { default: 'array' }],
'@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/dot-notation': 'off',
'@stylistic/ts/indent': [
'error',
2,
{
ignoredNodes: [
'TSTypeParameterInstantiation',
'FunctionExpression > .params[decorators.length > 0]',
'FunctionExpression > .params > :matches(Decorator, :not(:first-child))',
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
],
SwitchCase: 1
}
],
'@stylistic/ts/member-delimiter-style': [
'error',
{
multiline: { delimiter: 'semi', requireLast: true },
singleline: { delimiter: 'semi', requireLast: false }
}
],
'@typescript-eslint/member-ordering': [
'error',
{
default: [
'signature',
'call-signature',
'public-static-field',
'protected-static-field',
'private-static-field',
'#private-static-field',
'public-decorated-field',
'protected-decorated-field',
'private-decorated-field',
'public-instance-field',
'protected-instance-field',
'private-instance-field',
'#private-instance-field',
'public-abstract-field',
'protected-abstract-field',
'public-field',
'protected-field',
'private-field',
'#private-field',
'static-field',
'instance-field',
'abstract-field',
'decorated-field',
'field',
'static-initialization',
'public-constructor',
'protected-constructor',
'private-constructor',
'constructor',
'public-static-method',
'protected-static-method',
'private-static-method',
'#private-static-method',
'public-decorated-method',
'protected-decorated-method',
'private-decorated-method',
'public-instance-method',
'protected-instance-method',
'private-instance-method',
'#private-instance-method',
'public-abstract-method',
'protected-abstract-method',
'public-method',
'protected-method',
'private-method',
'#private-method',
'static-method',
'instance-method',
'abstract-method',
'decorated-method',
'method'
]
}
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-invalid-this': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/prefer-namespace-keyword': 'error',
'@typescript-eslint/no-unused-expressions': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', ignoreRestSiblings: true }],
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/prefer-function-type': 'error',
'@stylistic/ts/quotes': ['error', 'single', { avoidEscape: true }],
'@stylistic/ts/semi': ['error', 'always'],
'@stylistic/ts/type-annotation-spacing': 'error',
'@typescript-eslint/unified-signatures': 'error',
'@stylistic/js/array-bracket-spacing': 'error',
'@stylistic/ts/comma-dangle': ['error', 'never'],
'@stylistic/ts/comma-spacing': 'error',
'@stylistic/js/comma-style': 'error',
complexity: ['warn', { max: 20 }],
curly: 'off',
'eol-last': 'error',
'id-denylist': ['warn', 'e', 'cb', 'i', 'x', 'c', 'y', 'any', 'string', 'String', 'Undefined', 'undefined', 'callback'],
'max-len': ['error', { code: 150, ignoreComments: true }],
'new-parens': 'error',
'newline-per-chained-call': 'error',
'no-bitwise': 'off',
'no-cond-assign': 'error',
'no-empty': 'off',
'no-eval': 'error',
'@stylistic/js/no-multi-spaces': 'error',
'@stylistic/js/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }],
'no-new-wrappers': 'error',
'no-restricted-imports': ['error', 'rxjs/Rx'],
'no-throw-literal': 'error',
'no-trailing-spaces': 'error',
'no-undef-init': 'error',
'no-unsafe-finally': 'error',
'no-var': 'error',
'one-var': ['error', 'never'],
'prefer-const': 'error',
'@stylistic/ts/space-before-blocks': 'error',
'@stylistic/js/space-before-function-paren': ['error', { anonymous: 'never', asyncArrow: 'always', named: 'never' }],
'@stylistic/ts/space-infix-ops': 'error',
'@stylistic/js/space-in-parens': 'error',
'@stylistic/js/space-unary-ops': 'error',
'@stylistic/js/spaced-comment': ['error', 'always', { markers: ['/'] }],
'@stylistic/js/block-spacing': ['error', 'always'],
'nonblock-statement-body-position': ['error', 'below'],
'max-statements-per-line': ['error', { max: 1 }],
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: '*', next: 'if' },
{ blankLine: 'always', prev: 'if', next: '*' },
{ blankLine: 'always', prev: '*', next: 'block-like' },
{ blankLine: 'always', prev: 'block-like', next: '*' },
{ blankLine: 'always', prev: 'function', next: '*' },
{ blankLine: 'always', prev: 'class', next: '*' },
{ blankLine: 'always', prev: 'const', next: '*' },
{ blankLine: 'always', prev: 'let', next: '*' },
{ blankLine: 'always', prev: 'var', next: '*' },
{ blankLine: 'never', prev: 'const', next: 'const' },
{ blankLine: 'never', prev: 'let', next: 'let' },
{ blankLine: 'never', prev: 'var', next: 'var' }
]
}
},
{
files: ['src/app/**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {
'@angular-eslint/template/button-has-type': 'warn',
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
'@angular-eslint/template/eqeqeq': 'error',
'@angular-eslint/template/prefer-control-flow': 'error',
'@angular-eslint/template/prefer-ngsrc': 'warn',
'@angular-eslint/template/prefer-self-closing-tags': 'warn',
'@angular-eslint/template/use-track-by-function': 'warn',
'@angular-eslint/template/no-negated-async': 'warn',
'@angular-eslint/template/no-call-expression': 'off'
}
}
);
// IMPORTANT: Formatting is handled by Prettier; ESLint validates logic/accessibility.

View File

@@ -28,7 +28,8 @@
"electron:build:all": "npm run build:prod && electron-builder --win --mac --linux", "electron:build:all": "npm run build:prod && electron-builder --win --mac --linux",
"build:prod:all": "npm run build:prod && cd server && npm run build", "build:prod:all": "npm run build:prod && cd server && npm run build",
"build:prod:win": "npm run build:prod:all && electron-builder --win", "build:prod:win": "npm run build:prod:all && electron-builder --win",
"dev": "npm run electron:full" "dev": "npm run electron:full",
"lint": "eslint . --ext .ts,.html"
}, },
"prettier": { "prettier": {
"printWidth": 100, "printWidth": 100,
@@ -78,16 +79,22 @@
"@angular/build": "^21.0.4", "@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4", "@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "^21.2.0",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"eslint": "^9.39.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "^8.56.1",
"wait-on": "^7.2.0" "wait-on": "^7.2.0"
}, },
"build": { "build": {

48
server/dist/db.d.ts vendored
View File

@@ -1,43 +1,43 @@
export declare function initDB(): Promise<void>; export declare function initDB(): Promise<void>;
export interface AuthUser { export interface AuthUser {
id: string; id: string;
username: string; username: string;
passwordHash: string; passwordHash: string;
displayName: string; displayName: string;
createdAt: number; createdAt: number;
} }
export declare function getUserByUsername(username: string): Promise<AuthUser | null>; export declare function getUserByUsername(username: string): Promise<AuthUser | null>;
export declare function getUserById(id: string): Promise<AuthUser | null>; export declare function getUserById(id: string): Promise<AuthUser | null>;
export declare function createUser(user: AuthUser): Promise<void>; export declare function createUser(user: AuthUser): Promise<void>;
export interface ServerInfo { export interface ServerInfo {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
ownerId: string; ownerId: string;
ownerPublicKey: string; ownerPublicKey: string;
isPrivate: boolean; isPrivate: boolean;
maxUsers: number; maxUsers: number;
currentUsers: number; currentUsers: number;
tags: string[]; tags: string[];
createdAt: number; createdAt: number;
lastSeen: number; lastSeen: number;
} }
export declare function getAllPublicServers(): Promise<ServerInfo[]>; export declare function getAllPublicServers(): Promise<ServerInfo[]>;
export declare function getServerById(id: string): Promise<ServerInfo | null>; export declare function getServerById(id: string): Promise<ServerInfo | null>;
export declare function upsertServer(server: ServerInfo): Promise<void>; export declare function upsertServer(server: ServerInfo): Promise<void>;
export declare function deleteServer(id: string): Promise<void>; export declare function deleteServer(id: string): Promise<void>;
export interface JoinRequest { export interface JoinRequest {
id: string; id: string;
serverId: string; serverId: string;
userId: string; userId: string;
userPublicKey: string; userPublicKey: string;
displayName: string; displayName: string;
status: 'pending' | 'approved' | 'rejected'; status: 'pending' | 'approved' | 'rejected';
createdAt: number; createdAt: number;
} }
export declare function createJoinRequest(req: JoinRequest): Promise<void>; export declare function createJoinRequest(req: JoinRequest): Promise<void>;
export declare function getJoinRequestById(id: string): Promise<JoinRequest | null>; export declare function getJoinRequestById(id: string): Promise<JoinRequest | null>;
export declare function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]>; export declare function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]>;
export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void>; export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void>;
export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise<void>; export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise<void>;
//# sourceMappingURL=db.d.ts.map // # sourceMappingURL=db.d.ts.map

View File

@@ -1,2 +1,2 @@
export {}; export {};
//# sourceMappingURL=index.d.ts.map // # sourceMappingURL=index.d.ts.map

View File

@@ -7,19 +7,23 @@ const DATA_DIR = path.join(process.cwd(), 'data');
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite'); const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
function ensureDataDir() { function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(DATA_DIR))
fs.mkdirSync(DATA_DIR, { recursive: true });
} }
let SQL: any = null; let SQL: any = null;
let db: any | null = null; let db: any | null = null;
export async function initDB(): Promise<void> { export async function initDB(): Promise<void> {
if (db) return; if (db)
return;
SQL = await initSqlJs({ locateFile: (file: string) => require.resolve('sql.js/dist/sql-wasm.wasm') }); SQL = await initSqlJs({ locateFile: (file: string) => require.resolve('sql.js/dist/sql-wasm.wasm') });
ensureDataDir(); ensureDataDir();
if (fs.existsSync(DB_FILE)) { if (fs.existsSync(DB_FILE)) {
const fileBuffer = fs.readFileSync(DB_FILE); const fileBuffer = fs.readFileSync(DB_FILE);
db = new SQL.Database(new Uint8Array(fileBuffer)); db = new SQL.Database(new Uint8Array(fileBuffer));
} else { } else {
db = new SQL.Database(); db = new SQL.Database();
@@ -68,9 +72,12 @@ export async function initDB(): Promise<void> {
} }
function persist(): void { function persist(): void {
if (!db) return; if (!db)
return;
const data = db.export(); const data = db.export();
const buffer = Buffer.from(data); const buffer = Buffer.from(data);
fs.writeFileSync(DB_FILE, buffer); fs.writeFileSync(DB_FILE, buffer);
} }
@@ -87,46 +94,61 @@ export interface AuthUser {
} }
export async function getUserByUsername(username: string): Promise<AuthUser | null> { export async function getUserByUsername(username: string): Promise<AuthUser | null> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1'); const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1');
stmt.bind([username]); stmt.bind([username]);
let row: AuthUser | null = null; let row: AuthUser | null = null;
if (stmt.step()) { if (stmt.step()) {
const r = stmt.getAsObject() as any; const r = stmt.getAsObject() as any;
row = { row = {
id: String(r.id), id: String(r.id),
username: String(r.username), username: String(r.username),
passwordHash: String(r.passwordHash), passwordHash: String(r.passwordHash),
displayName: String(r.displayName), displayName: String(r.displayName),
createdAt: Number(r.createdAt), createdAt: Number(r.createdAt)
}; };
} }
stmt.free(); stmt.free();
return row; return row;
} }
export async function getUserById(id: string): Promise<AuthUser | null> { export async function getUserById(id: string): Promise<AuthUser | null> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1'); const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1');
stmt.bind([id]); stmt.bind([id]);
let row: AuthUser | null = null; let row: AuthUser | null = null;
if (stmt.step()) { if (stmt.step()) {
const r = stmt.getAsObject() as any; const r = stmt.getAsObject() as any;
row = { row = {
id: String(r.id), id: String(r.id),
username: String(r.username), username: String(r.username),
passwordHash: String(r.passwordHash), passwordHash: String(r.passwordHash),
displayName: String(r.displayName), displayName: String(r.displayName),
createdAt: Number(r.createdAt), createdAt: Number(r.createdAt)
}; };
} }
stmt.free(); stmt.free();
return row; return row;
} }
export async function createUser(user: AuthUser): Promise<void> { export async function createUser(user: AuthUser): Promise<void> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt = db!.prepare('INSERT INTO users (id, username, passwordHash, displayName, createdAt) VALUES (?, ?, ?, ?, ?)'); const stmt = db!.prepare('INSERT INTO users (id, username, passwordHash, displayName, createdAt) VALUES (?, ?, ?, ?, ?)');
stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]); stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]);
stmt.step(); stmt.step();
stmt.free(); stmt.free();
@@ -163,39 +185,51 @@ function rowToServer(r: any): ServerInfo {
currentUsers: Number(r.currentUsers), currentUsers: Number(r.currentUsers),
tags: JSON.parse(String(r.tags || '[]')), tags: JSON.parse(String(r.tags || '[]')),
createdAt: Number(r.createdAt), createdAt: Number(r.createdAt),
lastSeen: Number(r.lastSeen), lastSeen: Number(r.lastSeen)
}; };
} }
export async function getAllPublicServers(): Promise<ServerInfo[]> { export async function getAllPublicServers(): Promise<ServerInfo[]> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt: any = db!.prepare('SELECT * FROM servers WHERE isPrivate = 0'); const stmt: any = db!.prepare('SELECT * FROM servers WHERE isPrivate = 0');
const results: ServerInfo[] = []; const results: ServerInfo[] = [];
while (stmt.step()) { while (stmt.step()) {
results.push(rowToServer(stmt.getAsObject())); results.push(rowToServer(stmt.getAsObject()));
} }
stmt.free(); stmt.free();
return results; return results;
} }
export async function getServerById(id: string): Promise<ServerInfo | null> { export async function getServerById(id: string): Promise<ServerInfo | null> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt: any = db!.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1'); const stmt: any = db!.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1');
stmt.bind([id]); stmt.bind([id]);
let row: ServerInfo | null = null; let row: ServerInfo | null = null;
if (stmt.step()) { if (stmt.step()) {
row = rowToServer(stmt.getAsObject()); row = rowToServer(stmt.getAsObject());
} }
stmt.free(); stmt.free();
return row; return row;
} }
export async function upsertServer(server: ServerInfo): Promise<void> { export async function upsertServer(server: ServerInfo): Promise<void> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt = db!.prepare(` const stmt = db!.prepare(`
INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen) INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
stmt.bind([ stmt.bind([
server.id, server.id,
server.name, server.name,
@@ -207,7 +241,7 @@ export async function upsertServer(server: ServerInfo): Promise<void> {
server.currentUsers, server.currentUsers,
JSON.stringify(server.tags), JSON.stringify(server.tags),
server.createdAt, server.createdAt,
server.lastSeen, server.lastSeen
]); ]);
stmt.step(); stmt.step();
stmt.free(); stmt.free();
@@ -215,13 +249,17 @@ export async function upsertServer(server: ServerInfo): Promise<void> {
} }
export async function deleteServer(id: string): Promise<void> { export async function deleteServer(id: string): Promise<void> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt = db!.prepare('DELETE FROM servers WHERE id = ?'); const stmt = db!.prepare('DELETE FROM servers WHERE id = ?');
stmt.bind([id]); stmt.bind([id]);
stmt.step(); stmt.step();
stmt.free(); stmt.free();
// Also clean up related join requests // Also clean up related join requests
const jStmt = db!.prepare('DELETE FROM join_requests WHERE serverId = ?'); const jStmt = db!.prepare('DELETE FROM join_requests WHERE serverId = ?');
jStmt.bind([id]); jStmt.bind([id]);
jStmt.step(); jStmt.step();
jStmt.free(); jStmt.free();
@@ -250,16 +288,19 @@ function rowToJoinRequest(r: any): JoinRequest {
userPublicKey: String(r.userPublicKey), userPublicKey: String(r.userPublicKey),
displayName: String(r.displayName), displayName: String(r.displayName),
status: String(r.status) as JoinRequest['status'], status: String(r.status) as JoinRequest['status'],
createdAt: Number(r.createdAt), createdAt: Number(r.createdAt)
}; };
} }
export async function createJoinRequest(req: JoinRequest): Promise<void> { export async function createJoinRequest(req: JoinRequest): Promise<void> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt = db!.prepare(` const stmt = db!.prepare(`
INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt) INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]); stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]);
stmt.step(); stmt.step();
stmt.free(); stmt.free();
@@ -267,32 +308,45 @@ export async function createJoinRequest(req: JoinRequest): Promise<void> {
} }
export async function getJoinRequestById(id: string): Promise<JoinRequest | null> { export async function getJoinRequestById(id: string): Promise<JoinRequest | null> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1'); const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1');
stmt.bind([id]); stmt.bind([id]);
let row: JoinRequest | null = null; let row: JoinRequest | null = null;
if (stmt.step()) { if (stmt.step()) {
row = rowToJoinRequest(stmt.getAsObject()); row = rowToJoinRequest(stmt.getAsObject());
} }
stmt.free(); stmt.free();
return row; return row;
} }
export async function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]> { export async function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?'); const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?');
stmt.bind([serverId, 'pending']); stmt.bind([serverId, 'pending']);
const results: JoinRequest[] = []; const results: JoinRequest[] = [];
while (stmt.step()) { while (stmt.step()) {
results.push(rowToJoinRequest(stmt.getAsObject())); results.push(rowToJoinRequest(stmt.getAsObject()));
} }
stmt.free(); stmt.free();
return results; return results;
} }
export async function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void> { export async function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void> {
if (!db) await initDB(); if (!db)
await initDB();
const stmt = db!.prepare('UPDATE join_requests SET status = ? WHERE id = ?'); const stmt = db!.prepare('UPDATE join_requests SET status = ? WHERE id = ?');
stmt.bind([status, id]); stmt.bind([status, id]);
stmt.step(); stmt.step();
stmt.free(); stmt.free();
@@ -300,9 +354,12 @@ export async function updateJoinRequestStatus(id: string, status: JoinRequest['s
} }
export async function deleteStaleJoinRequests(maxAgeMs: number): Promise<void> { export async function deleteStaleJoinRequests(maxAgeMs: number): Promise<void> {
if (!db) await initDB(); if (!db)
await initDB();
const cutoff = Date.now() - maxAgeMs; const cutoff = Date.now() - maxAgeMs;
const stmt = db!.prepare('DELETE FROM join_requests WHERE createdAt < ?'); const stmt = db!.prepare('DELETE FROM join_requests WHERE createdAt < ?');
stmt.bind([cutoff]); stmt.bind([cutoff]);
stmt.step(); stmt.step();
stmt.free(); stmt.free();

View File

@@ -23,8 +23,8 @@ app.use(express.json());
interface ConnectedUser { interface ConnectedUser {
oderId: string; oderId: string;
ws: WebSocket; ws: WebSocket;
serverIds: Set<string>; // all servers the user is a member of serverIds: Set<string>; // all servers the user is a member of
viewedServerId?: string; // currently viewed/active server viewedServerId?: string; // currently viewed/active server
displayName?: string; displayName?: string;
} }
@@ -46,21 +46,23 @@ import {
updateJoinRequestStatus, updateJoinRequestStatus,
deleteStaleJoinRequests, deleteStaleJoinRequests,
ServerInfo, ServerInfo,
JoinRequest, JoinRequest
} from './db'; } from './db';
function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); } function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw)
.digest('hex'); }
// REST API Routes // REST API Routes
// Health check endpoint // Health check endpoint
app.get('/api/health', async (req, res) => { app.get('/api/health', async (req, res) => {
const allServers = await getAllPublicServers(); const allServers = await getAllPublicServers();
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: Date.now(), timestamp: Date.now(),
serverCount: allServers.length, serverCount: allServers.length,
connectedUsers: connectedUsers.size, connectedUsers: connectedUsers.size
}); });
}); });
@@ -73,6 +75,7 @@ app.get('/api/time', (req, res) => {
app.get('/api/image-proxy', async (req, res) => { app.get('/api/image-proxy', async (req, res) => {
try { try {
const url = String(req.query.url || ''); const url = String(req.query.url || '');
if (!/^https?:\/\//i.test(url)) { if (!/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Invalid URL' }); return res.status(400).json({ error: 'Invalid URL' });
} }
@@ -80,6 +83,7 @@ app.get('/api/image-proxy', async (req, res) => {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000); const timeout = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url, { redirect: 'follow', signal: controller.signal }); const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { if (!response.ok) {
@@ -87,12 +91,14 @@ app.get('/api/image-proxy', async (req, res) => {
} }
const contentType = response.headers.get('content-type') || ''; const contentType = response.headers.get('content-type') || '';
if (!contentType.toLowerCase().startsWith('image/')) { if (!contentType.toLowerCase().startsWith('image/')) {
return res.status(415).json({ error: 'Unsupported content type' }); return res.status(415).json({ error: 'Unsupported content type' });
} }
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit
if (arrayBuffer.byteLength > MAX_BYTES) { if (arrayBuffer.byteLength > MAX_BYTES) {
return res.status(413).json({ error: 'Image too large' }); return res.status(413).json({ error: 'Image too large' });
} }
@@ -104,6 +110,7 @@ app.get('/api/image-proxy', async (req, res) => {
if ((err as any)?.name === 'AbortError') { if ((err as any)?.name === 'AbortError') {
return res.status(504).json({ error: 'Timeout fetching image' }); return res.status(504).json({ error: 'Timeout fetching image' });
} }
console.error('Image proxy error:', err); console.error('Image proxy error:', err);
res.status(502).json({ error: 'Failed to fetch image' }); res.status(502).json({ error: 'Failed to fetch image' });
} }
@@ -112,10 +119,17 @@ app.get('/api/image-proxy', async (req, res) => {
// Auth // Auth
app.post('/api/users/register', async (req, res) => { app.post('/api/users/register', async (req, res) => {
const { username, password, displayName } = req.body; const { username, password, displayName } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Missing username/password' });
if (!username || !password)
return res.status(400).json({ error: 'Missing username/password' });
const exists = await getUserByUsername(username); const exists = await getUserByUsername(username);
if (exists) return res.status(409).json({ error: 'Username taken' });
if (exists)
return res.status(409).json({ error: 'Username taken' });
const user = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() }; const user = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
await createUser(user); await createUser(user);
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName }); res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
}); });
@@ -123,7 +137,10 @@ app.post('/api/users/register', async (req, res) => {
app.post('/api/users/login', async (req, res) => { app.post('/api/users/login', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
const user = await getUserByUsername(username); const user = await getUserByUsername(username);
if (!user || user.passwordHash !== hashPassword(password)) return res.status(401).json({ error: 'Invalid credentials' });
if (!user || user.passwordHash !== hashPassword(password))
return res.status(401).json({ error: 'Invalid credentials' });
res.json({ id: user.id, username: user.username, displayName: user.displayName }); res.json({ id: user.id, username: user.username, displayName: user.displayName });
}); });
@@ -137,20 +154,25 @@ app.get('/api/servers', async (req, res) => {
.filter(s => { .filter(s => {
if (q) { if (q) {
const query = String(q).toLowerCase(); const query = String(q).toLowerCase();
return s.name.toLowerCase().includes(query) || return s.name.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query); s.description?.toLowerCase().includes(query);
} }
return true; return true;
}) })
.filter(s => { .filter(s => {
if (tags) { if (tags) {
const tagList = String(tags).split(','); const tagList = String(tags).split(',');
return tagList.some(t => s.tags.includes(t)); return tagList.some(t => s.tags.includes(t));
} }
return true; return true;
}); });
const total = results.length; const total = results.length;
results = results.slice(Number(offset), Number(offset) + Number(limit)); results = results.slice(Number(offset), Number(offset) + Number(limit));
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) }); res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
@@ -176,7 +198,7 @@ app.post('/api/servers', async (req, res) => {
currentUsers: 0, currentUsers: 0,
tags: tags ?? [], tags: tags ?? [],
createdAt: Date.now(), createdAt: Date.now(),
lastSeen: Date.now(), lastSeen: Date.now()
}; };
await upsertServer(server); await upsertServer(server);
@@ -187,8 +209,8 @@ app.post('/api/servers', async (req, res) => {
app.put('/api/servers/:id', async (req, res) => { app.put('/api/servers/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { ownerId, ...updates } = req.body; const { ownerId, ...updates } = req.body;
const server = await getServerById(id); const server = await getServerById(id);
if (!server) { if (!server) {
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
} }
@@ -198,6 +220,7 @@ app.put('/api/servers/:id', async (req, res) => {
} }
const updated: ServerInfo = { ...server, ...updates, lastSeen: Date.now() }; const updated: ServerInfo = { ...server, ...updates, lastSeen: Date.now() };
await upsertServer(updated); await upsertServer(updated);
res.json(updated); res.json(updated);
}); });
@@ -206,16 +229,18 @@ app.put('/api/servers/:id', async (req, res) => {
app.post('/api/servers/:id/heartbeat', async (req, res) => { app.post('/api/servers/:id/heartbeat', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { currentUsers } = req.body; const { currentUsers } = req.body;
const server = await getServerById(id); const server = await getServerById(id);
if (!server) { if (!server) {
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
} }
server.lastSeen = Date.now(); server.lastSeen = Date.now();
if (typeof currentUsers === 'number') { if (typeof currentUsers === 'number') {
server.currentUsers = currentUsers; server.currentUsers = currentUsers;
} }
await upsertServer(server); await upsertServer(server);
res.json({ ok: true }); res.json({ ok: true });
@@ -225,8 +250,8 @@ app.post('/api/servers/:id/heartbeat', async (req, res) => {
app.delete('/api/servers/:id', async (req, res) => { app.delete('/api/servers/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { ownerId } = req.body; const { ownerId } = req.body;
const server = await getServerById(id); const server = await getServerById(id);
if (!server) { if (!server) {
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
} }
@@ -243,8 +268,8 @@ app.delete('/api/servers/:id', async (req, res) => {
app.post('/api/servers/:id/join', async (req, res) => { app.post('/api/servers/:id/join', async (req, res) => {
const { id: serverId } = req.params; const { id: serverId } = req.params;
const { userId, userPublicKey, displayName } = req.body; const { userId, userPublicKey, displayName } = req.body;
const server = await getServerById(serverId); const server = await getServerById(serverId);
if (!server) { if (!server) {
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
} }
@@ -257,7 +282,7 @@ app.post('/api/servers/:id/join', async (req, res) => {
userPublicKey, userPublicKey,
displayName, displayName,
status: server.isPrivate ? 'pending' : 'approved', status: server.isPrivate ? 'pending' : 'approved',
createdAt: Date.now(), createdAt: Date.now()
}; };
await createJoinRequest(request); await createJoinRequest(request);
@@ -266,7 +291,7 @@ app.post('/api/servers/:id/join', async (req, res) => {
if (server.isPrivate) { if (server.isPrivate) {
notifyServerOwner(server.ownerId, { notifyServerOwner(server.ownerId, {
type: 'join_request', type: 'join_request',
request, request
}); });
} }
@@ -277,8 +302,8 @@ app.post('/api/servers/:id/join', async (req, res) => {
app.get('/api/servers/:id/requests', async (req, res) => { app.get('/api/servers/:id/requests', async (req, res) => {
const { id: serverId } = req.params; const { id: serverId } = req.params;
const { ownerId } = req.query; const { ownerId } = req.query;
const server = await getServerById(serverId); const server = await getServerById(serverId);
if (!server) { if (!server) {
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
} }
@@ -288,6 +313,7 @@ app.get('/api/servers/:id/requests', async (req, res) => {
} }
const requests = await getPendingRequestsForServer(serverId); const requests = await getPendingRequestsForServer(serverId);
res.json({ requests }); res.json({ requests });
}); });
@@ -295,13 +321,14 @@ app.get('/api/servers/:id/requests', async (req, res) => {
app.put('/api/requests/:id', async (req, res) => { app.put('/api/requests/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { ownerId, status } = req.body; const { ownerId, status } = req.body;
const request = await getJoinRequestById(id); const request = await getJoinRequestById(id);
if (!request) { if (!request) {
return res.status(404).json({ error: 'Request not found' }); return res.status(404).json({ error: 'Request not found' });
} }
const server = await getServerById(request.serverId); const server = await getServerById(request.serverId);
if (!server || server.ownerId !== ownerId) { if (!server || server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' }); return res.status(403).json({ error: 'Not authorized' });
} }
@@ -312,7 +339,7 @@ app.put('/api/requests/:id', async (req, res) => {
// Notify the requester // Notify the requester
notifyUser(request.userId, { notifyUser(request.userId, {
type: 'request_update', type: 'request_update',
request: updated, request: updated
}); });
res.json(updated); res.json(updated);
@@ -325,16 +352,19 @@ function buildServer() {
const certDir = path.resolve(__dirname, '..', '..', '.certs'); const certDir = path.resolve(__dirname, '..', '..', '.certs');
const certFile = path.join(certDir, 'localhost.crt'); const certFile = path.join(certDir, 'localhost.crt');
const keyFile = path.join(certDir, 'localhost.key'); const keyFile = path.join(certDir, 'localhost.key');
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) { if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
console.error(`SSL=true but certs not found in ${certDir}`); console.error(`SSL=true but certs not found in ${certDir}`);
console.error('Run ./generate-cert.sh first.'); console.error('Run ./generate-cert.sh first.');
process.exit(1); process.exit(1);
} }
return createHttpsServer( return createHttpsServer(
{ cert: fs.readFileSync(certFile), key: fs.readFileSync(keyFile) }, { cert: fs.readFileSync(certFile), key: fs.readFileSync(keyFile) },
app, app
); );
} }
return createHttpServer(app); return createHttpServer(app);
} }
@@ -343,11 +373,13 @@ const wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
const connectionId = uuidv4(); const connectionId = uuidv4();
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() }); connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
ws.on('message', (data) => { ws.on('message', (data) => {
try { try {
const message = JSON.parse(data.toString()); const message = JSON.parse(data.toString());
handleWebSocketMessage(connectionId, message); handleWebSocketMessage(connectionId, message);
} catch (err) { } catch (err) {
console.error('Invalid WebSocket message:', err); console.error('Invalid WebSocket message:', err);
@@ -356,6 +388,7 @@ wss.on('connection', (ws: WebSocket) => {
ws.on('close', () => { ws.on('close', () => {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
if (user) { if (user) {
// Notify all servers the user was a member of // Notify all servers the user was a member of
user.serverIds.forEach((sid) => { user.serverIds.forEach((sid) => {
@@ -363,10 +396,11 @@ wss.on('connection', (ws: WebSocket) => {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName, displayName: user.displayName,
serverId: sid, serverId: sid
}, user.oderId); }, user.oderId);
}); });
} }
connectedUsers.delete(connectionId); connectedUsers.delete(connectionId);
}); });
@@ -376,7 +410,9 @@ wss.on('connection', (ws: WebSocket) => {
function handleWebSocketMessage(connectionId: string, message: any): void { function handleWebSocketMessage(connectionId: string, message: any): void {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
if (!user) return;
if (!user)
return;
switch (message.type) { switch (message.type) {
case 'identify': case 'identify':
@@ -391,6 +427,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
case 'join_server': { case 'join_server': {
const sid = message.serverId; const sid = message.serverId;
const isNew = !user.serverIds.has(sid); const isNew = !user.serverIds.has(sid);
user.serverIds.add(sid); user.serverIds.add(sid);
user.viewedServerId = sid; user.viewedServerId = sid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
@@ -405,7 +442,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
user.ws.send(JSON.stringify({ user.ws.send(JSON.stringify({
type: 'server_users', type: 'server_users',
serverId: sid, serverId: sid,
users: usersInServer, users: usersInServer
})); }));
// Only broadcast user_joined if this is a brand-new join (not a re-view) // Only broadcast user_joined if this is a brand-new join (not a re-view)
@@ -414,15 +451,17 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
type: 'user_joined', type: 'user_joined',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName || 'Anonymous', displayName: user.displayName || 'Anonymous',
serverId: sid, serverId: sid
}, user.oderId); }, user.oderId);
} }
break; break;
} }
case 'view_server': { case 'view_server': {
// Just switch the viewed server without joining/leaving // Just switch the viewed server without joining/leaving
const viewSid = message.serverId; const viewSid = message.serverId;
user.viewedServerId = viewSid; user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`); console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
@@ -435,27 +474,31 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
user.ws.send(JSON.stringify({ user.ws.send(JSON.stringify({
type: 'server_users', type: 'server_users',
serverId: viewSid, serverId: viewSid,
users: viewUsers, users: viewUsers
})); }));
break; break;
} }
case 'leave_server': { case 'leave_server': {
const leaveSid = message.serverId || user.viewedServerId; const leaveSid = message.serverId || user.viewedServerId;
if (leaveSid) { if (leaveSid) {
user.serverIds.delete(leaveSid); user.serverIds.delete(leaveSid);
if (user.viewedServerId === leaveSid) { if (user.viewedServerId === leaveSid) {
user.viewedServerId = undefined; user.viewedServerId = undefined;
} }
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
broadcastToServer(leaveSid, { broadcastToServer(leaveSid, {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName || 'Anonymous', displayName: user.displayName || 'Anonymous',
serverId: leaveSid, serverId: leaveSid
}, user.oderId); }, user.oderId);
} }
break; break;
} }
@@ -465,21 +508,24 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
// Forward signaling messages to specific peer // Forward signaling messages to specific peer
console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`); console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`);
const targetUser = findUserByUserId(message.targetUserId); const targetUser = findUserByUserId(message.targetUserId);
if (targetUser) { if (targetUser) {
targetUser.ws.send(JSON.stringify({ targetUser.ws.send(JSON.stringify({
...message, ...message,
fromUserId: user.oderId, fromUserId: user.oderId
})); }));
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`); console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
} else { } else {
console.log(`Target user ${message.targetUserId} not found. Connected users:`, console.log(`Target user ${message.targetUserId} not found. Connected users:`,
Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName }))); Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName })));
} }
break; break;
case 'chat_message': { case 'chat_message': {
// Broadcast chat message to all users in the server // Broadcast chat message to all users in the server
const chatSid = message.serverId || user.viewedServerId; const chatSid = message.serverId || user.viewedServerId;
if (chatSid && user.serverIds.has(chatSid)) { if (chatSid && user.serverIds.has(chatSid)) {
broadcastToServer(chatSid, { broadcastToServer(chatSid, {
type: 'chat_message', type: 'chat_message',
@@ -487,23 +533,26 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
message: message.message, message: message.message,
senderId: user.oderId, senderId: user.oderId,
senderName: user.displayName, senderName: user.displayName,
timestamp: Date.now(), timestamp: Date.now()
}); });
} }
break; break;
} }
case 'typing': { case 'typing': {
// Broadcast typing indicator // Broadcast typing indicator
const typingSid = message.serverId || user.viewedServerId; const typingSid = message.serverId || user.viewedServerId;
if (typingSid && user.serverIds.has(typingSid)) { if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, { broadcastToServer(typingSid, {
type: 'user_typing', type: 'user_typing',
serverId: typingSid, serverId: typingSid,
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName, displayName: user.displayName
}, user.oderId); }, user.oderId);
} }
break; break;
} }
@@ -524,6 +573,7 @@ function broadcastToServer(serverId: string, message: any, excludeOderId?: strin
function notifyServerOwner(ownerId: string, message: any): void { function notifyServerOwner(ownerId: string, message: any): void {
const owner = findUserByUserId(ownerId); const owner = findUserByUserId(ownerId);
if (owner) { if (owner) {
owner.ws.send(JSON.stringify(message)); owner.ws.send(JSON.stringify(message));
} }
@@ -531,6 +581,7 @@ function notifyServerOwner(ownerId: string, message: any): void {
function notifyUser(oderId: string, message: any): void { function notifyUser(oderId: string, message: any): void {
const user = findUserByUserId(oderId); const user = findUserByUserId(oderId);
if (user) { if (user) {
user.ws.send(JSON.stringify(message)); user.ws.send(JSON.stringify(message));
} }
@@ -543,7 +594,7 @@ function findUserByUserId(oderId: string): ConnectedUser | undefined {
// Cleanup stale join requests periodically (older than 24 h) // Cleanup stale join requests periodically (older than 24 h)
setInterval(() => { setInterval(() => {
deleteStaleJoinRequests(24 * 60 * 60 * 1000).catch(err => deleteStaleJoinRequests(24 * 60 * 60 * 1000).catch(err =>
console.error('Failed to clean up stale join requests:', err), console.error('Failed to clean up stale join requests:', err)
); );
}, 60 * 1000); }, 60 * 1000);
@@ -551,11 +602,13 @@ initDB().then(() => {
server.listen(PORT, () => { server.listen(PORT, () => {
const proto = USE_SSL ? 'https' : 'http'; const proto = USE_SSL ? 'https' : 'http';
const wsProto = USE_SSL ? 'wss' : 'ws'; const wsProto = USE_SSL ? 'wss' : 'ws';
console.log(`🚀 MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`); console.log(`🚀 MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
console.log(` REST API: ${proto}://localhost:${PORT}/api`); console.log(` REST API: ${proto}://localhost:${PORT}/api`);
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`); console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
}); });
}).catch((err) => { })
console.error('Failed to initialize database:', err); .catch((err) => {
process.exit(1); console.error('Failed to initialize database:', err);
}); process.exit(1);
});

View File

@@ -24,14 +24,14 @@ export const appConfig: ApplicationConfig = {
provideStore({ provideStore({
messages: messagesReducer, messages: messagesReducer,
users: usersReducer, users: usersReducer,
rooms: roomsReducer, rooms: roomsReducer
}), }),
provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]), provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]),
provideStoreDevtools({ provideStoreDevtools({
maxAge: STORE_DEVTOOLS_MAX_AGE, maxAge: STORE_DEVTOOLS_MAX_AGE,
logOnly: !isDevMode(), logOnly: !isDevMode(),
autoPause: true, autoPause: true,
trace: false, trace: false
}), })
], ]
}; };

View File

@@ -5,33 +5,33 @@ export const routes: Routes = [
{ {
path: '', path: '',
redirectTo: 'search', redirectTo: 'search',
pathMatch: 'full', pathMatch: 'full'
}, },
{ {
path: 'login', path: 'login',
loadComponent: () => loadComponent: () =>
import('./features/auth/login/login.component').then((module) => module.LoginComponent), import('./features/auth/login/login.component').then((module) => module.LoginComponent)
}, },
{ {
path: 'register', path: 'register',
loadComponent: () => loadComponent: () =>
import('./features/auth/register/register.component').then((module) => module.RegisterComponent), import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
}, },
{ {
path: 'search', path: 'search',
loadComponent: () => loadComponent: () =>
import('./features/server-search/server-search.component').then( import('./features/server-search/server-search.component').then(
(module) => module.ServerSearchComponent (module) => module.ServerSearchComponent
), )
}, },
{ {
path: 'room/:roomId', path: 'room/:roomId',
loadComponent: () => loadComponent: () =>
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent), import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
}, },
{ {
path: 'settings', path: 'settings',
loadComponent: () => loadComponent: () =>
import('./features/settings/settings.component').then((module) => module.SettingsComponent), import('./features/settings/settings.component').then((module) => module.SettingsComponent)
}, }
]; ];

View File

@@ -1,3 +1,4 @@
/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */
import { Component, OnInit, inject, HostListener } from '@angular/core'; import { Component, OnInit, inject, HostListener } from '@angular/core';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -18,7 +19,7 @@ import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { import {
ROOM_URL_PATTERN, ROOM_URL_PATTERN,
STORAGE_KEY_CURRENT_USER_ID, STORAGE_KEY_CURRENT_USER_ID,
STORAGE_KEY_LAST_VISITED_ROUTE, STORAGE_KEY_LAST_VISITED_ROUTE
} from './core/constants'; } from './core/constants';
/** /**
@@ -35,10 +36,10 @@ import {
ServersRailComponent, ServersRailComponent,
TitleBarComponent, TitleBarComponent,
FloatingVoiceControlsComponent, FloatingVoiceControlsComponent,
SettingsModalComponent, SettingsModalComponent
], ],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss'
}) })
export class App implements OnInit { export class App implements OnInit {
private databaseService = inject(DatabaseService); private databaseService = inject(DatabaseService);
@@ -64,6 +65,7 @@ export class App implements OnInit {
// Initial time sync with active server // Initial time sync with active server
try { try {
const apiBase = this.servers.getApiBaseUrl(); const apiBase = this.servers.getApiBaseUrl();
await this.timeSync.syncWithEndpoint(apiBase); await this.timeSync.syncWithEndpoint(apiBase);
} catch {} } catch {}
@@ -75,14 +77,17 @@ export class App implements OnInit {
// If not authenticated, redirect to login; else restore last route // If not authenticated, redirect to login; else restore last route
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID); const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) { if (!currentUserId) {
if (this.router.url !== '/login' && this.router.url !== '/register') { if (this.router.url !== '/login' && this.router.url !== '/register') {
this.router.navigate(['/login']).catch(() => {}); this.router.navigate(['/login']).catch(() => {});
} }
} else { } else {
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE); const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
if (last && typeof last === 'string') { if (last && typeof last === 'string') {
const current = this.router.url; const current = this.router.url;
if (current === '/' || current === '/search') { if (current === '/' || current === '/search') {
this.router.navigate([last], { replaceUrl: true }).catch(() => {}); this.router.navigate([last], { replaceUrl: true }).catch(() => {});
} }
@@ -93,6 +98,7 @@ export class App implements OnInit {
this.router.events.subscribe((evt) => { this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) { if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url; const url = evt.urlAfterRedirects || evt.url;
// Store room route or search // Store room route or search
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url); localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
import { Injectable, inject, signal, effect } from '@angular/core'; import { Injectable, inject, signal, effect } from '@angular/core';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { WebRTCService } from './webrtc.service'; import { WebRTCService } from './webrtc.service';
@@ -7,10 +8,8 @@ import { DatabaseService } from './database.service';
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ /** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
/** Maximum file size (bytes) that is automatically saved to disk (Electron). */ /** Maximum file size (bytes) that is automatically saved to disk (Electron). */
const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/** /**
* EWMA smoothing weight for the *previous* speed estimate. * EWMA smoothing weight for the *previous* speed estimate.
* The complementary weight (1 this value) is applied to the * The complementary weight (1 this value) is applied to the
@@ -18,10 +17,8 @@ const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
*/ */
const EWMA_PREVIOUS_WEIGHT = 0.7; const EWMA_PREVIOUS_WEIGHT = 0.7;
const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT; const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT;
/** Fallback MIME type when none is provided by the sender. */ /** Fallback MIME type when none is provided by the sender. */
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
/** localStorage key used by the legacy attachment store (migration target). */ /** localStorage key used by the legacy attachment store (migration target). */
const LEGACY_STORAGE_KEY = 'metoyou_attachments'; const LEGACY_STORAGE_KEY = 'metoyou_attachments';
@@ -144,11 +141,13 @@ export class AttachmentService {
* {@link AttachmentMeta} (local paths are scrubbed). * {@link AttachmentMeta} (local paths are scrubbed).
*/ */
getAttachmentMetasForMessages( getAttachmentMetasForMessages(
messageIds: string[], messageIds: string[]
): Record<string, AttachmentMeta[]> { ): Record<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {}; const result: Record<string, AttachmentMeta[]> = {};
for (const messageId of messageIds) { for (const messageId of messageIds) {
const attachments = this.attachmentsByMessage.get(messageId); const attachments = this.attachmentsByMessage.get(messageId);
if (attachments && attachments.length > 0) { if (attachments && attachments.length > 0) {
result[messageId] = attachments.map((attachment) => ({ result[messageId] = attachments.map((attachment) => ({
id: attachment.id, id: attachment.id,
@@ -158,11 +157,12 @@ export class AttachmentService {
mime: attachment.mime, mime: attachment.mime,
isImage: attachment.isImage, isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId, uploaderPeerId: attachment.uploaderPeerId,
filePath: undefined, // never share local paths filePath: undefined, // never share local paths
savedPath: undefined, // never share local paths savedPath: undefined // never share local paths
})); }));
} }
} }
return result; return result;
} }
@@ -173,20 +173,24 @@ export class AttachmentService {
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer. * @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
*/ */
registerSyncedAttachments( registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>, attachmentMap: Record<string, AttachmentMeta[]>
): void { ): void {
const newAttachments: Attachment[] = []; const newAttachments: Attachment[] = [];
for (const [messageId, metas] of Object.entries(attachmentMap)) { for (const [messageId, metas] of Object.entries(attachmentMap)) {
const existing = this.attachmentsByMessage.get(messageId) ?? []; const existing = this.attachmentsByMessage.get(messageId) ?? [];
for (const meta of metas) { for (const meta of metas) {
const alreadyKnown = existing.find((entry) => entry.id === meta.id); const alreadyKnown = existing.find((entry) => entry.id === meta.id);
if (!alreadyKnown) { if (!alreadyKnown) {
const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 }; const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 };
existing.push(attachment); existing.push(attachment);
newAttachments.push(attachment); newAttachments.push(attachment);
} }
} }
if (existing.length > 0) { if (existing.length > 0) {
this.attachmentsByMessage.set(messageId, existing); this.attachmentsByMessage.set(messageId, existing);
} }
@@ -194,6 +198,7 @@ export class AttachmentService {
if (newAttachments.length > 0) { if (newAttachments.length > 0) {
this.touch(); this.touch();
for (const attachment of newAttachments) { for (const attachment of newAttachments) {
void this.persistAttachmentMeta(attachment); void this.persistAttachmentMeta(attachment);
} }
@@ -210,11 +215,14 @@ export class AttachmentService {
*/ */
requestFromAnyPeer(messageId: string, attachment: Attachment): void { requestFromAnyPeer(messageId: string, attachment: Attachment): void {
const connectedPeers = this.webrtc.getConnectedPeers(); const connectedPeers = this.webrtc.getConnectedPeers();
if (connectedPeers.length === 0) { if (connectedPeers.length === 0) {
console.warn('[Attachments] No connected peers to request file from'); console.warn('[Attachments] No connected peers to request file from');
return; return;
} }
const requestKey = this.buildRequestKey(messageId, attachment.id); const requestKey = this.buildRequestKey(messageId, attachment.id);
this.pendingRequests.set(requestKey, new Set()); this.pendingRequests.set(requestKey, new Set());
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId); this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
} }
@@ -224,9 +232,13 @@ export class AttachmentService {
*/ */
handleFileNotFound(payload: any): void { handleFileNotFound(payload: any): void {
const { messageId, fileId } = payload; const { messageId, fileId } = payload;
if (!messageId || !fileId) return;
if (!messageId || !fileId)
return;
const attachments = this.attachmentsByMessage.get(messageId) ?? []; const attachments = this.attachmentsByMessage.get(messageId) ?? [];
const attachment = attachments.find((entry) => entry.id === fileId); const attachment = attachments.find((entry) => entry.id === fileId);
this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId); this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
} }
@@ -260,7 +272,7 @@ export class AttachmentService {
async publishAttachments( async publishAttachments(
messageId: string, messageId: string,
files: File[], files: File[],
uploaderPeerId?: string, uploaderPeerId?: string
): Promise<void> { ): Promise<void> {
const attachments: Attachment[] = []; const attachments: Attachment[] = [];
@@ -275,8 +287,9 @@ export class AttachmentService {
isImage: file.type.startsWith('image/'), isImage: file.type.startsWith('image/'),
uploaderPeerId, uploaderPeerId,
filePath: (file as any)?.path, filePath: (file as any)?.path,
available: false, available: false
}; };
attachments.push(attachment); attachments.push(attachment);
// Retain the original File so we can serve file-request later // Retain the original File so we can serve file-request later
@@ -303,8 +316,8 @@ export class AttachmentService {
size: attachment.size, size: attachment.size,
mime: attachment.mime, mime: attachment.mime,
isImage: attachment.isImage, isImage: attachment.isImage,
uploaderPeerId, uploaderPeerId
}, }
} as any); } as any);
// Auto-stream small images // Auto-stream small images
@@ -314,6 +327,7 @@ export class AttachmentService {
} }
const existingList = this.attachmentsByMessage.get(messageId) ?? []; const existingList = this.attachmentsByMessage.get(messageId) ?? [];
this.attachmentsByMessage.set(messageId, [...existingList, ...attachments]); this.attachmentsByMessage.set(messageId, [...existingList, ...attachments]);
this.touch(); this.touch();
@@ -325,11 +339,15 @@ export class AttachmentService {
/** Handle a `file-announce` event from a peer. */ /** Handle a `file-announce` event from a peer. */
handleFileAnnounce(payload: any): void { handleFileAnnounce(payload: any): void {
const { messageId, file } = payload; const { messageId, file } = payload;
if (!messageId || !file) return;
if (!messageId || !file)
return;
const list = this.attachmentsByMessage.get(messageId) ?? []; const list = this.attachmentsByMessage.get(messageId) ?? [];
const alreadyKnown = list.find((entry) => entry.id === file.id); const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown) return;
if (alreadyKnown)
return;
const attachment: Attachment = { const attachment: Attachment = {
id: file.id, id: file.id,
@@ -340,8 +358,9 @@ export class AttachmentService {
isImage: !!file.isImage, isImage: !!file.isImage,
uploaderPeerId: file.uploaderPeerId, uploaderPeerId: file.uploaderPeerId,
available: false, available: false,
receivedBytes: 0, receivedBytes: 0
}; };
list.push(attachment); list.push(attachment);
this.attachmentsByMessage.set(messageId, list); this.attachmentsByMessage.set(messageId, list);
this.touch(); this.touch();
@@ -357,22 +376,27 @@ export class AttachmentService {
*/ */
handleFileChunk(payload: any): void { handleFileChunk(payload: any): void {
const { messageId, fileId, index, total, data } = payload; const { messageId, fileId, index, total, data } = payload;
if ( if (
!messageId || !fileId || !messageId || !fileId ||
typeof index !== 'number' || typeof index !== 'number' ||
typeof total !== 'number' || typeof total !== 'number' ||
!data !data
) return; )
return;
const list = this.attachmentsByMessage.get(messageId) ?? []; const list = this.attachmentsByMessage.get(messageId) ?? [];
const attachment = list.find((entry) => entry.id === fileId); const attachment = list.find((entry) => entry.id === fileId);
if (!attachment) return;
if (!attachment)
return;
const decodedBytes = this.base64ToUint8Array(data); const decodedBytes = this.base64ToUint8Array(data);
const assemblyKey = `${messageId}:${fileId}`; const assemblyKey = `${messageId}:${fileId}`;
// Initialise assembly buffer on first chunk // Initialise assembly buffer on first chunk
let chunkBuffer = this.chunkBuffers.get(assemblyKey); let chunkBuffer = this.chunkBuffers.get(assemblyKey);
if (!chunkBuffer) { if (!chunkBuffer) {
chunkBuffer = new Array(total); chunkBuffer = new Array(total);
this.chunkBuffers.set(assemblyKey, chunkBuffer); this.chunkBuffers.set(assemblyKey, chunkBuffer);
@@ -388,14 +412,19 @@ export class AttachmentService {
// Update progress stats // Update progress stats
const now = Date.now(); const now = Date.now();
const previousReceived = attachment.receivedBytes ?? 0; const previousReceived = attachment.receivedBytes ?? 0;
attachment.receivedBytes = previousReceived + decodedBytes.byteLength; attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
if (!attachment.startedAtMs) attachment.startedAtMs = now; if (!attachment.startedAtMs)
if (!attachment.lastUpdateMs) attachment.lastUpdateMs = now; attachment.startedAtMs = now;
if (!attachment.lastUpdateMs)
attachment.lastUpdateMs = now;
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs); const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000; const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
const previousSpeed = attachment.speedBps ?? instantaneousBps; const previousSpeed = attachment.speedBps ?? instantaneousBps;
attachment.speedBps = attachment.speedBps =
EWMA_PREVIOUS_WEIGHT * previousSpeed + EWMA_PREVIOUS_WEIGHT * previousSpeed +
EWMA_CURRENT_WEIGHT * instantaneousBps; EWMA_CURRENT_WEIGHT * instantaneousBps;
@@ -405,10 +434,13 @@ export class AttachmentService {
// Check if assembly is complete // Check if assembly is complete
const receivedChunkCount = this.chunkCounts.get(assemblyKey) ?? 0; const receivedChunkCount = this.chunkCounts.get(assemblyKey) ?? 0;
if (receivedChunkCount === total || (attachment.receivedBytes ?? 0) >= attachment.size) { if (receivedChunkCount === total || (attachment.receivedBytes ?? 0) >= attachment.size) {
const completeBuffer = this.chunkBuffers.get(assemblyKey); const completeBuffer = this.chunkBuffers.get(assemblyKey);
if (completeBuffer && completeBuffer.every((part) => part instanceof ArrayBuffer)) { if (completeBuffer && completeBuffer.every((part) => part instanceof ArrayBuffer)) {
const blob = new Blob(completeBuffer, { type: attachment.mime }); const blob = new Blob(completeBuffer, { type: attachment.mime });
attachment.available = true; attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob); attachment.objectUrl = URL.createObjectURL(blob);
@@ -441,10 +473,13 @@ export class AttachmentService {
*/ */
async handleFileRequest(payload: any): Promise<void> { async handleFileRequest(payload: any): Promise<void> {
const { messageId, fileId, fromPeerId } = payload; const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId) return;
if (!messageId || !fileId || !fromPeerId)
return;
// 1. In-memory original // 1. In-memory original
const exactKey = `${messageId}:${fileId}`; const exactKey = `${messageId}:${fileId}`;
let originalFile = this.originalFiles.get(exactKey); let originalFile = this.originalFiles.get(exactKey);
// 1b. Fallback: search by fileId suffix (handles rare messageId drift) // 1b. Fallback: search by fileId suffix (handles rare messageId drift)
@@ -490,10 +525,12 @@ export class AttachmentService {
if (attachment?.isImage && electronApi?.getAppDataPath && electronApi?.fileExists && electronApi?.readFile) { if (attachment?.isImage && electronApi?.getAppDataPath && electronApi?.fileExists && electronApi?.readFile) {
try { try {
const appDataPath = await electronApi.getAppDataPath(); const appDataPath = await electronApi.getAppDataPath();
if (appDataPath) { if (appDataPath) {
const roomName = await this.resolveCurrentRoomName(); const roomName = await this.resolveCurrentRoomName();
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
const diskPath = `${appDataPath}/server/${sanitisedRoom}/image/${attachment.filename}`; const diskPath = `${appDataPath}/server/${sanitisedRoom}/image/${attachment.filename}`;
if (await electronApi.fileExists(diskPath)) { if (await electronApi.fileExists(diskPath)) {
await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, diskPath); await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, diskPath);
return; return;
@@ -508,6 +545,7 @@ export class AttachmentService {
const response = await fetch(attachment.objectUrl); const response = await fetch(attachment.objectUrl);
const blob = await response.blob(); const blob = await response.blob();
const file = new File([blob], attachment.filename, { type: attachment.mime }); const file = new File([blob], attachment.filename, { type: attachment.mime });
await this.streamFileToPeer(fromPeerId, messageId, fileId, file); await this.streamFileToPeer(fromPeerId, messageId, fileId, file);
return; return;
} catch { /* fall through */ } } catch { /* fall through */ }
@@ -517,7 +555,7 @@ export class AttachmentService {
this.webrtc.sendToPeer(fromPeerId, { this.webrtc.sendToPeer(fromPeerId, {
type: 'file-not-found', type: 'file-not-found',
messageId, messageId,
fileId, fileId
} as any); } as any);
} }
@@ -527,11 +565,14 @@ export class AttachmentService {
*/ */
cancelRequest(messageId: string, attachment: Attachment): void { cancelRequest(messageId: string, attachment: Attachment): void {
const targetPeerId = attachment.uploaderPeerId; const targetPeerId = attachment.uploaderPeerId;
if (!targetPeerId) return;
if (!targetPeerId)
return;
try { try {
// Reset assembly state // Reset assembly state
const assemblyKey = `${messageId}:${attachment.id}`; const assemblyKey = `${messageId}:${attachment.id}`;
this.chunkBuffers.delete(assemblyKey); this.chunkBuffers.delete(assemblyKey);
this.chunkCounts.delete(assemblyKey); this.chunkCounts.delete(assemblyKey);
@@ -542,8 +583,10 @@ export class AttachmentService {
if (attachment.objectUrl) { if (attachment.objectUrl) {
try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ }
attachment.objectUrl = undefined; attachment.objectUrl = undefined;
} }
attachment.available = false; attachment.available = false;
this.touch(); this.touch();
@@ -551,7 +594,7 @@ export class AttachmentService {
this.webrtc.sendToPeer(targetPeerId, { this.webrtc.sendToPeer(targetPeerId, {
type: 'file-cancel', type: 'file-cancel',
messageId, messageId,
fileId: attachment.id, fileId: attachment.id
} as any); } as any);
} catch { /* best-effort */ } } catch { /* best-effort */ }
} }
@@ -562,9 +605,12 @@ export class AttachmentService {
*/ */
handleFileCancel(payload: any): void { handleFileCancel(payload: any): void {
const { messageId, fileId, fromPeerId } = payload; const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId) return;
if (!messageId || !fileId || !fromPeerId)
return;
this.cancelledTransfers.add( this.cancelledTransfers.add(
this.buildTransferKey(messageId, fileId, fromPeerId), this.buildTransferKey(messageId, fileId, fromPeerId)
); );
} }
@@ -576,7 +622,7 @@ export class AttachmentService {
messageId: string, messageId: string,
fileId: string, fileId: string,
targetPeerId: string, targetPeerId: string,
file: File, file: File
): Promise<void> { ): Promise<void> {
this.originalFiles.set(`${messageId}:${fileId}`, file); this.originalFiles.set(`${messageId}:${fileId}`, file);
await this.streamFileToPeer(targetPeerId, messageId, fileId, file); await this.streamFileToPeer(targetPeerId, messageId, fileId, file);
@@ -600,7 +646,7 @@ export class AttachmentService {
/** Check whether a specific transfer has been cancelled. */ /** Check whether a specific transfer has been cancelled. */
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean { private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
return this.cancelledTransfers.has( return this.cancelledTransfers.has(
this.buildTransferKey(messageId, fileId, targetPeerId), this.buildTransferKey(messageId, fileId, targetPeerId)
); );
} }
@@ -616,7 +662,7 @@ export class AttachmentService {
private sendFileRequestToNextPeer( private sendFileRequestToNextPeer(
messageId: string, messageId: string,
fileId: string, fileId: string,
preferredPeerId?: string, preferredPeerId?: string
): boolean { ): boolean {
const connectedPeers = this.webrtc.getConnectedPeers(); const connectedPeers = this.webrtc.getConnectedPeers();
const requestKey = this.buildRequestKey(messageId, fileId); const requestKey = this.buildRequestKey(messageId, fileId);
@@ -624,6 +670,7 @@ export class AttachmentService {
// Pick the best untried peer: preferred first, then any // Pick the best untried peer: preferred first, then any
let targetPeerId: string | undefined; let targetPeerId: string | undefined;
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) { if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
targetPeerId = preferredPeerId; targetPeerId = preferredPeerId;
} else { } else {
@@ -641,7 +688,7 @@ export class AttachmentService {
this.webrtc.sendToPeer(targetPeerId, { this.webrtc.sendToPeer(targetPeerId, {
type: 'file-request', type: 'file-request',
messageId, messageId,
fileId, fileId
} as any); } as any);
return true; return true;
} }
@@ -650,9 +697,10 @@ export class AttachmentService {
private async streamFileToPeers( private async streamFileToPeers(
messageId: string, messageId: string,
fileId: string, fileId: string,
file: File, file: File
): Promise<void> { ): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
let offset = 0; let offset = 0;
let chunkIndex = 0; let chunkIndex = 0;
@@ -667,7 +715,7 @@ export class AttachmentService {
fileId, fileId,
index: chunkIndex, index: chunkIndex,
total: totalChunks, total: totalChunks,
data: base64, data: base64
} as any); } as any);
offset += FILE_CHUNK_SIZE_BYTES; offset += FILE_CHUNK_SIZE_BYTES;
@@ -680,14 +728,16 @@ export class AttachmentService {
targetPeerId: string, targetPeerId: string,
messageId: string, messageId: string,
fileId: string, fileId: string,
file: File, file: File
): Promise<void> { ): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
let offset = 0; let offset = 0;
let chunkIndex = 0; let chunkIndex = 0;
while (offset < file.size) { while (offset < file.size) {
if (this.isTransferCancelled(targetPeerId, messageId, fileId)) break; if (this.isTransferCancelled(targetPeerId, messageId, fileId))
break;
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer(); const arrayBuffer = await slice.arrayBuffer();
@@ -699,7 +749,7 @@ export class AttachmentService {
fileId, fileId,
index: chunkIndex, index: chunkIndex,
total: totalChunks, total: totalChunks,
data: base64, data: base64
} as any); } as any);
offset += FILE_CHUNK_SIZE_BYTES; offset += FILE_CHUNK_SIZE_BYTES;
@@ -715,7 +765,7 @@ export class AttachmentService {
targetPeerId: string, targetPeerId: string,
messageId: string, messageId: string,
fileId: string, fileId: string,
diskPath: string, diskPath: string
): Promise<void> { ): Promise<void> {
const electronApi = (window as any)?.electronAPI; const electronApi = (window as any)?.electronAPI;
const base64Full = await electronApi.readFile(diskPath); const base64Full = await electronApi.readFile(diskPath);
@@ -723,14 +773,15 @@ export class AttachmentService {
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (this.isTransferCancelled(targetPeerId, messageId, fileId)) break; if (this.isTransferCancelled(targetPeerId, messageId, fileId))
break;
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES; const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES); const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES);
const slice = fileBytes.subarray(start, end); const slice = fileBytes.subarray(start, end);
const sliceBuffer = (slice.buffer as ArrayBuffer).slice( const sliceBuffer = (slice.buffer as ArrayBuffer).slice(
slice.byteOffset, slice.byteOffset,
slice.byteOffset + slice.byteLength, slice.byteOffset + slice.byteLength
); );
const base64Chunk = this.arrayBufferToBase64(sliceBuffer); const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
@@ -740,7 +791,7 @@ export class AttachmentService {
fileId, fileId,
index: chunkIndex, index: chunkIndex,
total: totalChunks, total: totalChunks,
data: base64Chunk, data: base64Chunk
} as any); } as any);
} }
} }
@@ -753,7 +804,9 @@ export class AttachmentService {
try { try {
const electronApi = (window as any)?.electronAPI; const electronApi = (window as any)?.electronAPI;
const appDataPath: string | undefined = await electronApi?.getAppDataPath?.(); const appDataPath: string | undefined = await electronApi?.getAppDataPath?.();
if (!appDataPath) return;
if (!appDataPath)
return;
const roomName = await this.resolveCurrentRoomName(); const roomName = await this.resolveCurrentRoomName();
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
@@ -762,13 +815,14 @@ export class AttachmentService {
: attachment.mime.startsWith('image/') : attachment.mime.startsWith('image/')
? 'image' ? 'image'
: 'files'; : 'files';
const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`; const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`;
await electronApi.ensureDir(directoryPath); await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer); const base64 = this.arrayBufferToBase64(arrayBuffer);
const diskPath = `${directoryPath}/${attachment.filename}`; const diskPath = `${directoryPath}/${attachment.filename}`;
await electronApi.writeFile(diskPath, base64); await electronApi.writeFile(diskPath, base64);
attachment.savedPath = diskPath; attachment.savedPath = diskPath;
@@ -779,14 +833,17 @@ export class AttachmentService {
/** On startup, try loading previously saved files from disk (Electron). */ /** On startup, try loading previously saved files from disk (Electron). */
private async tryLoadSavedFiles(): Promise<void> { private async tryLoadSavedFiles(): Promise<void> {
const electronApi = (window as any)?.electronAPI; const electronApi = (window as any)?.electronAPI;
if (!electronApi?.fileExists || !electronApi?.readFile) return;
if (!electronApi?.fileExists || !electronApi?.readFile)
return;
try { try {
let hasChanges = false; let hasChanges = false;
for (const [, attachments] of this.attachmentsByMessage) { for (const [, attachments] of this.attachmentsByMessage) {
for (const attachment of attachments) { for (const attachment of attachments) {
if (attachment.available) continue; if (attachment.available)
continue;
// 1. Try savedPath (disk cache) // 1. Try savedPath (disk cache)
if (attachment.savedPath) { if (attachment.savedPath) {
@@ -805,10 +862,13 @@ export class AttachmentService {
if (await electronApi.fileExists(attachment.filePath)) { if (await electronApi.fileExists(attachment.filePath)) {
this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.filePath)); this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.filePath));
hasChanges = true; hasChanges = true;
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
const response = await fetch(attachment.objectUrl!); const response = await fetch(attachment.objectUrl!);
void this.saveFileToDisk(attachment, await response.blob()); void this.saveFileToDisk(attachment, await response.blob());
} }
continue; continue;
} }
} catch { /* fall through */ } } catch { /* fall through */ }
@@ -816,7 +876,8 @@ export class AttachmentService {
} }
} }
if (hasChanges) this.touch(); if (hasChanges)
this.touch();
} catch { /* startup load is best-effort */ } } catch { /* startup load is best-effort */ }
} }
@@ -827,15 +888,19 @@ export class AttachmentService {
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void { private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
const bytes = this.base64ToUint8Array(base64); const bytes = this.base64ToUint8Array(base64);
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime }); const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
attachment.objectUrl = URL.createObjectURL(blob); attachment.objectUrl = URL.createObjectURL(blob);
attachment.available = true; attachment.available = true;
const file = new File([blob], attachment.filename, { type: attachment.mime }); const file = new File([blob], attachment.filename, { type: attachment.mime });
this.originalFiles.set(`${attachment.messageId}:${attachment.id}`, file); this.originalFiles.set(`${attachment.messageId}:${attachment.id}`, file);
} }
/** Save attachment metadata to the database (without file content). */ /** Save attachment metadata to the database (without file content). */
private async persistAttachmentMeta(attachment: Attachment): Promise<void> { private async persistAttachmentMeta(attachment: Attachment): Promise<void> {
if (!this.database.isReady()) return; if (!this.database.isReady())
return;
try { try {
await this.database.saveAttachment({ await this.database.saveAttachment({
id: attachment.id, id: attachment.id,
@@ -846,7 +911,7 @@ export class AttachmentService {
isImage: attachment.isImage, isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId, uploaderPeerId: attachment.uploaderPeerId,
filePath: attachment.filePath, filePath: attachment.filePath,
savedPath: attachment.savedPath, savedPath: attachment.savedPath
}); });
} catch { /* persistence is best-effort */ } } catch { /* persistence is best-effort */ }
} }
@@ -856,12 +921,15 @@ export class AttachmentService {
try { try {
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
const grouped = new Map<string, Attachment[]>(); const grouped = new Map<string, Attachment[]>();
for (const record of allRecords) { for (const record of allRecords) {
const attachment: Attachment = { ...record, available: false }; const attachment: Attachment = { ...record, available: false };
const bucket = grouped.get(record.messageId) ?? []; const bucket = grouped.get(record.messageId) ?? [];
bucket.push(attachment); bucket.push(attachment);
grouped.set(record.messageId, bucket); grouped.set(record.messageId, bucket);
} }
this.attachmentsByMessage = grouped; this.attachmentsByMessage = grouped;
this.touch(); this.touch();
} catch { /* load is best-effort */ } } catch { /* load is best-effort */ }
@@ -871,13 +939,18 @@ export class AttachmentService {
private async migrateFromLocalStorage(): Promise<void> { private async migrateFromLocalStorage(): Promise<void> {
try { try {
const raw = localStorage.getItem(LEGACY_STORAGE_KEY); const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!raw) return;
if (!raw)
return;
const legacyRecords: AttachmentMeta[] = JSON.parse(raw); const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
for (const meta of legacyRecords) { for (const meta of legacyRecords) {
const existing = this.attachmentsByMessage.get(meta.messageId) ?? []; const existing = this.attachmentsByMessage.get(meta.messageId) ?? [];
if (!existing.find((entry) => entry.id === meta.id)) { if (!existing.find((entry) => entry.id === meta.id)) {
const attachment: Attachment = { ...meta, available: false }; const attachment: Attachment = { ...meta, available: false };
existing.push(attachment); existing.push(attachment);
this.attachmentsByMessage.set(meta.messageId, existing); this.attachmentsByMessage.set(meta.messageId, existing);
void this.persistAttachmentMeta(attachment); void this.persistAttachmentMeta(attachment);
@@ -911,10 +984,13 @@ export class AttachmentService {
/** Convert an ArrayBuffer to a base-64 string. */ /** Convert an ArrayBuffer to a base-64 string. */
private arrayBufferToBase64(buffer: ArrayBuffer): string { private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = ''; let binary = '';
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) { for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]); binary += String.fromCharCode(bytes[index]);
} }
return btoa(binary); return btoa(binary);
} }
@@ -922,9 +998,11 @@ export class AttachmentService {
private base64ToUint8Array(base64: string): Uint8Array { private base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64); const binary = atob(base64);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) { for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index); bytes[index] = binary.charCodeAt(index);
} }
return bytes; return bytes;
} }
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -43,11 +44,12 @@ export class AuthService {
if (serverId) { if (serverId) {
endpoint = this.serverDirectory.servers().find( endpoint = this.serverDirectory.servers().find(
(server) => server.id === serverId, (server) => server.id === serverId
); );
} }
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer(); const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
return activeEndpoint ? `${activeEndpoint.url}/api` : DEFAULT_API_BASE; return activeEndpoint ? `${activeEndpoint.url}/api` : DEFAULT_API_BASE;
} }
@@ -68,10 +70,11 @@ export class AuthService {
serverId?: string; serverId?: string;
}): Observable<LoginResponse> { }): Observable<LoginResponse> {
const url = `${this.endpointFor(params.serverId)}/users/register`; const url = `${this.endpointFor(params.serverId)}/users/register`;
return this.http.post<LoginResponse>(url, { return this.http.post<LoginResponse>(url, {
username: params.username, username: params.username,
password: params.password, password: params.password,
displayName: params.displayName, displayName: params.displayName
}); });
} }
@@ -90,9 +93,10 @@ export class AuthService {
serverId?: string; serverId?: string;
}): Observable<LoginResponse> { }): Observable<LoginResponse> {
const url = `${this.endpointFor(params.serverId)}/users/login`; const url = `${this.endpointFor(params.serverId)}/users/login`;
return this.http.post<LoginResponse>(url, { return this.http.post<LoginResponse>(url, {
username: params.username, username: params.username,
password: params.password, password: params.password
}); });
} }
} }

View File

@@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models'; import { Message, User, Room, Reaction, BanEntry } from '../models';
/** IndexedDB database name for the MetoYou application. */ /** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou'; const DATABASE_NAME = 'metoyou';
/** IndexedDB schema version — bump when adding/changing object stores. */ /** IndexedDB schema version — bump when adding/changing object stores. */
const DATABASE_VERSION = 2; const DATABASE_VERSION = 2;
/** Names of every object store used by the application. */ /** Names of every object store used by the application. */
const STORE_MESSAGES = 'messages'; const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users'; const STORE_USERS = 'users';
@@ -15,7 +14,6 @@ const STORE_REACTIONS = 'reactions';
const STORE_BANS = 'bans'; const STORE_BANS = 'bans';
const STORE_META = 'meta'; const STORE_META = 'meta';
const STORE_ATTACHMENTS = 'attachments'; const STORE_ATTACHMENTS = 'attachments';
/** All object store names, used when clearing the entire database. */ /** All object store names, used when clearing the entire database. */
const ALL_STORE_NAMES: string[] = [ const ALL_STORE_NAMES: string[] = [
STORE_MESSAGES, STORE_MESSAGES,
@@ -24,7 +22,7 @@ const ALL_STORE_NAMES: string[] = [
STORE_REACTIONS, STORE_REACTIONS,
STORE_BANS, STORE_BANS,
STORE_ATTACHMENTS, STORE_ATTACHMENTS,
STORE_META, STORE_META
]; ];
/** /**
@@ -41,7 +39,9 @@ export class BrowserDatabaseService {
/** Open (or create) the IndexedDB database. Safe to call multiple times. */ /** Open (or create) the IndexedDB database. Safe to call multiple times. */
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.database) return; if (this.database)
return;
this.database = await this.openDatabase(); this.database = await this.openDatabase();
} }
@@ -59,8 +59,9 @@ export class BrowserDatabaseService {
*/ */
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> { async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>( const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId, STORE_MESSAGES, 'roomId', roomId
); );
return allRoomMessages return allRoomMessages
.sort((first, second) => first.timestamp - second.timestamp) .sort((first, second) => first.timestamp - second.timestamp)
.slice(offset, offset + limit); .slice(offset, offset + limit);
@@ -74,6 +75,7 @@ export class BrowserDatabaseService {
/** Apply partial updates to an existing message. */ /** Apply partial updates to an existing message. */
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> { async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const existing = await this.get<Message>(STORE_MESSAGES, messageId); const existing = await this.get<Message>(STORE_MESSAGES, messageId);
if (existing) { if (existing) {
await this.put(STORE_MESSAGES, { ...existing, ...updates }); await this.put(STORE_MESSAGES, { ...existing, ...updates });
} }
@@ -87,12 +89,14 @@ export class BrowserDatabaseService {
/** Remove every message belonging to a room. */ /** Remove every message belonging to a room. */
async clearRoomMessages(roomId: string): Promise<void> { async clearRoomMessages(roomId: string): Promise<void> {
const messages = await this.getAllFromIndex<Message>( const messages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId, STORE_MESSAGES, 'roomId', roomId
); );
const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite'); const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite');
for (const message of messages) { for (const message of messages) {
transaction.objectStore(STORE_MESSAGES).delete(message.id); transaction.objectStore(STORE_MESSAGES).delete(message.id);
} }
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
@@ -102,11 +106,12 @@ export class BrowserDatabaseService {
*/ */
async saveReaction(reaction: Reaction): Promise<void> { async saveReaction(reaction: Reaction): Promise<void> {
const existing = await this.getAllFromIndex<Reaction>( const existing = await this.getAllFromIndex<Reaction>(
STORE_REACTIONS, 'messageId', reaction.messageId, STORE_REACTIONS, 'messageId', reaction.messageId
); );
const isDuplicate = existing.some( const isDuplicate = existing.some(
(entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji, (entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji
); );
if (!isDuplicate) { if (!isDuplicate) {
await this.put(STORE_REACTIONS, reaction); await this.put(STORE_REACTIONS, reaction);
} }
@@ -115,11 +120,12 @@ export class BrowserDatabaseService {
/** Remove a specific reaction (identified by user + emoji + message). */ /** Remove a specific reaction (identified by user + emoji + message). */
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> { async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
const reactions = await this.getAllFromIndex<Reaction>( const reactions = await this.getAllFromIndex<Reaction>(
STORE_REACTIONS, 'messageId', messageId, STORE_REACTIONS, 'messageId', messageId
); );
const target = reactions.find( const target = reactions.find(
(entry) => entry.userId === userId && entry.emoji === emoji, (entry) => entry.userId === userId && entry.emoji === emoji
); );
if (target) { if (target) {
await this.deleteRecord(STORE_REACTIONS, target.id); await this.deleteRecord(STORE_REACTIONS, target.id);
} }
@@ -143,9 +149,12 @@ export class BrowserDatabaseService {
/** Retrieve the last-authenticated ("current") user, or `null`. */ /** Retrieve the last-authenticated ("current") user, or `null`. */
async getCurrentUser(): Promise<User | null> { async getCurrentUser(): Promise<User | null> {
const meta = await this.get<{ id: string; value: string }>( const meta = await this.get<{ id: string; value: string }>(
STORE_META, 'currentUserId', STORE_META, 'currentUserId'
); );
if (!meta) return null;
if (!meta)
return null;
return this.getUser(meta.value); return this.getUser(meta.value);
} }
@@ -165,6 +174,7 @@ export class BrowserDatabaseService {
/** Apply partial updates to an existing user. */ /** Apply partial updates to an existing user. */
async updateUser(userId: string, updates: Partial<User>): Promise<void> { async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const existing = await this.get<User>(STORE_USERS, userId); const existing = await this.get<User>(STORE_USERS, userId);
if (existing) { if (existing) {
await this.put(STORE_USERS, { ...existing, ...updates }); await this.put(STORE_USERS, { ...existing, ...updates });
} }
@@ -194,6 +204,7 @@ export class BrowserDatabaseService {
/** Apply partial updates to an existing room. */ /** Apply partial updates to an existing room. */
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> { async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const existing = await this.get<Room>(STORE_ROOMS, roomId); const existing = await this.get<Room>(STORE_ROOMS, roomId);
if (existing) { if (existing) {
await this.put(STORE_ROOMS, { ...existing, ...updates }); await this.put(STORE_ROOMS, { ...existing, ...updates });
} }
@@ -208,10 +219,11 @@ export class BrowserDatabaseService {
async removeBan(oderId: string): Promise<void> { async removeBan(oderId: string): Promise<void> {
const allBans = await this.getAll<BanEntry>(STORE_BANS); const allBans = await this.getAll<BanEntry>(STORE_BANS);
const match = allBans.find((ban) => ban.oderId === oderId); const match = allBans.find((ban) => ban.oderId === oderId);
if (match) { if (match) {
await this.deleteRecord( await this.deleteRecord(
STORE_BANS, STORE_BANS,
(match as any).id ?? match.oderId, (match as any).id ?? match.oderId
); );
} }
} }
@@ -223,17 +235,19 @@ export class BrowserDatabaseService {
*/ */
async getBansForRoom(roomId: string): Promise<BanEntry[]> { async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const allBans = await this.getAllFromIndex<BanEntry>( const allBans = await this.getAllFromIndex<BanEntry>(
STORE_BANS, 'roomId', roomId, STORE_BANS, 'roomId', roomId
); );
const now = Date.now(); const now = Date.now();
return allBans.filter( return allBans.filter(
(ban) => !ban.expiresAt || ban.expiresAt > now, (ban) => !ban.expiresAt || ban.expiresAt > now
); );
} }
/** Check whether a specific user is currently banned from a room. */ /** Check whether a specific user is currently banned from a room. */
async isUserBanned(userId: string, roomId: string): Promise<boolean> { async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const activeBans = await this.getBansForRoom(roomId); const activeBans = await this.getBansForRoom(roomId);
return activeBans.some((ban) => ban.oderId === userId); return activeBans.some((ban) => ban.oderId === userId);
} }
@@ -255,23 +269,29 @@ export class BrowserDatabaseService {
/** Delete all attachment records for a message. */ /** Delete all attachment records for a message. */
async deleteAttachmentsForMessage(messageId: string): Promise<void> { async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const attachments = await this.getAllFromIndex<any>( const attachments = await this.getAllFromIndex<any>(
STORE_ATTACHMENTS, 'messageId', messageId, STORE_ATTACHMENTS, 'messageId', messageId
); );
if (attachments.length === 0) return;
if (attachments.length === 0)
return;
const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite'); const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite');
for (const attachment of attachments) { for (const attachment of attachments) {
transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id); transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id);
} }
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
/** Wipe every object store, removing all persisted data. */ /** Wipe every object store, removing all persisted data. */
async clearAllData(): Promise<void> { async clearAllData(): Promise<void> {
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite'); const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
for (const storeName of ALL_STORE_NAMES) { for (const storeName of ALL_STORE_NAMES) {
transaction.objectStore(storeName).clear(); transaction.objectStore(storeName).clear();
} }
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
@@ -292,27 +312,37 @@ export class BrowserDatabaseService {
if (!database.objectStoreNames.contains(STORE_MESSAGES)) { if (!database.objectStoreNames.contains(STORE_MESSAGES)) {
const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' }); const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' });
messagesStore.createIndex('roomId', 'roomId', { unique: false }); messagesStore.createIndex('roomId', 'roomId', { unique: false });
} }
if (!database.objectStoreNames.contains(STORE_USERS)) { if (!database.objectStoreNames.contains(STORE_USERS)) {
database.createObjectStore(STORE_USERS, { keyPath: 'id' }); database.createObjectStore(STORE_USERS, { keyPath: 'id' });
} }
if (!database.objectStoreNames.contains(STORE_ROOMS)) { if (!database.objectStoreNames.contains(STORE_ROOMS)) {
database.createObjectStore(STORE_ROOMS, { keyPath: 'id' }); database.createObjectStore(STORE_ROOMS, { keyPath: 'id' });
} }
if (!database.objectStoreNames.contains(STORE_REACTIONS)) { if (!database.objectStoreNames.contains(STORE_REACTIONS)) {
const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' }); const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' });
reactionsStore.createIndex('messageId', 'messageId', { unique: false }); reactionsStore.createIndex('messageId', 'messageId', { unique: false });
} }
if (!database.objectStoreNames.contains(STORE_BANS)) { if (!database.objectStoreNames.contains(STORE_BANS)) {
const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' }); const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' });
bansStore.createIndex('roomId', 'roomId', { unique: false }); bansStore.createIndex('roomId', 'roomId', { unique: false });
} }
if (!database.objectStoreNames.contains(STORE_META)) { if (!database.objectStoreNames.contains(STORE_META)) {
database.createObjectStore(STORE_META, { keyPath: 'id' }); database.createObjectStore(STORE_META, { keyPath: 'id' });
} }
if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) { if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) {
const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' }); const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
attachmentsStore.createIndex('messageId', 'messageId', { unique: false }); attachmentsStore.createIndex('messageId', 'messageId', { unique: false });
} }
}; };
@@ -325,7 +355,7 @@ export class BrowserDatabaseService {
/** Create an IndexedDB transaction on one or more stores. */ /** Create an IndexedDB transaction on one or more stores. */
private createTransaction( private createTransaction(
stores: string | string[], stores: string | string[],
mode: IDBTransactionMode = 'readonly', mode: IDBTransactionMode = 'readonly'
): IDBTransaction { ): IDBTransaction {
return this.database!.transaction(stores, mode); return this.database!.transaction(stores, mode);
} }
@@ -343,6 +373,7 @@ export class BrowserDatabaseService {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName); const transaction = this.createTransaction(storeName);
const request = transaction.objectStore(storeName).get(key); const request = transaction.objectStore(storeName).get(key);
request.onsuccess = () => resolve(request.result as T | undefined); request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
@@ -353,6 +384,7 @@ export class BrowserDatabaseService {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName); const transaction = this.createTransaction(storeName);
const request = transaction.objectStore(storeName).getAll(); const request = transaction.objectStore(storeName).getAll();
request.onsuccess = () => resolve(request.result as T[]); request.onsuccess = () => resolve(request.result as T[]);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
@@ -362,12 +394,13 @@ export class BrowserDatabaseService {
private getAllFromIndex<T>( private getAllFromIndex<T>(
storeName: string, storeName: string,
indexName: string, indexName: string,
key: IDBValidKey, key: IDBValidKey
): Promise<T[]> { ): Promise<T[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName); const transaction = this.createTransaction(storeName);
const index = transaction.objectStore(storeName).index(indexName); const index = transaction.objectStore(storeName).index(indexName);
const request = index.getAll(key); const request = index.getAll(key);
request.onsuccess = () => resolve(request.result as T[]); request.onsuccess = () => resolve(request.result as T[]);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
@@ -377,6 +410,7 @@ export class BrowserDatabaseService {
private put(storeName: string, value: any): Promise<void> { private put(storeName: string, value: any): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName, 'readwrite'); const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(value); transaction.objectStore(storeName).put(value);
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
@@ -387,6 +421,7 @@ export class BrowserDatabaseService {
private deleteRecord(storeName: string, key: IDBValidKey): Promise<void> { private deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.createTransaction(storeName, 'readwrite'); const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).delete(key); transaction.objectStore(storeName).delete(key);
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
import { inject, Injectable, signal } from '@angular/core'; import { inject, Injectable, signal } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models'; import { Message, User, Room, Reaction, BanEntry } from '../models';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';

View File

@@ -20,7 +20,9 @@ export class ElectronDatabaseService {
/** Initialise the SQLite database via the main-process IPC bridge. */ /** Initialise the SQLite database via the main-process IPC bridge. */
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.isInitialised) return; if (this.isInitialised)
return;
await this.api.initialize(); await this.api.initialize();
this.isInitialised = true; this.isInitialised = true;
} }

View File

@@ -13,7 +13,8 @@ export class ExternalLinkService {
/** Open a URL externally. Only http/https URLs are allowed. */ /** Open a URL externally. Only http/https URLs are allowed. */
open(url: string): void { open(url: string): void {
if (!url || !(url.startsWith('http://') || url.startsWith('https://'))) return; if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
return;
if (this.platform.isElectron) { if (this.platform.isElectron) {
(window as any).electronAPI?.openExternal(url); (window as any).electronAPI?.openExternal(url);
@@ -28,20 +29,28 @@ export class ExternalLinkService {
*/ */
handleClick(evt: MouseEvent): boolean { handleClick(evt: MouseEvent): boolean {
const target = (evt.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null; const target = (evt.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null;
if (!target) return false;
if (!target)
return false;
const href = target.href; // resolved full URL const href = target.href; // resolved full URL
if (!href) return false;
if (!href)
return false;
// Skip non-navigable URLs // Skip non-navigable URLs
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:')) return false; if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
return false;
// Skip same-page anchors // Skip same-page anchors
const rawAttr = target.getAttribute('href'); const rawAttr = target.getAttribute('href');
if (rawAttr?.startsWith('#')) return false;
if (rawAttr?.startsWith('#'))
return false;
// Skip Angular router links // Skip Angular router links
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link')) return false; if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
return false;
evt.preventDefault(); evt.preventDefault();
evt.stopPropagation(); evt.stopPropagation();

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
/** /**
@@ -8,18 +9,15 @@ import { Injectable, signal } from '@angular/core';
export enum AppSound { export enum AppSound {
Joining = 'joining', Joining = 'joining',
Leave = 'leave', Leave = 'leave',
Notification = 'notification', Notification = 'notification'
} }
/** Path prefix for audio assets (served from the `assets/audio/` folder). */ /** Path prefix for audio assets (served from the `assets/audio/` folder). */
const AUDIO_BASE = '/assets/audio'; const AUDIO_BASE = '/assets/audio';
/** File extension used for all sound-effect assets. */ /** File extension used for all sound-effect assets. */
const AUDIO_EXT = 'wav'; const AUDIO_EXT = 'wav';
/** localStorage key for persisting notification volume. */ /** localStorage key for persisting notification volume. */
const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume'; const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume';
/** Default notification volume (0 1). */ /** Default notification volume (0 1). */
const DEFAULT_VOLUME = 0.2; const DEFAULT_VOLUME = 0.2;
@@ -49,6 +47,7 @@ export class NotificationAudioService {
private preload(): void { private preload(): void {
for (const sound of Object.values(AppSound)) { for (const sound of Object.values(AppSound)) {
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`); const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`);
audio.preload = 'auto'; audio.preload = 'auto';
this.cache.set(sound, audio); this.cache.set(sound, audio);
} }
@@ -58,11 +57,15 @@ export class NotificationAudioService {
private loadVolume(): number { private loadVolume(): number {
try { try {
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME); const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME);
if (raw !== null) { if (raw !== null) {
const parsed = parseFloat(raw); const parsed = parseFloat(raw);
if (!isNaN(parsed)) return Math.max(0, Math.min(1, parsed));
if (!isNaN(parsed))
return Math.max(0, Math.min(1, parsed));
} }
} catch {} } catch {}
return DEFAULT_VOLUME; return DEFAULT_VOLUME;
} }
@@ -73,7 +76,9 @@ export class NotificationAudioService {
*/ */
setNotificationVolume(volume: number): void { setNotificationVolume(volume: number): void {
const clamped = Math.max(0, Math.min(1, volume)); const clamped = Math.max(0, Math.min(1, volume));
this.notificationVolume.set(clamped); this.notificationVolume.set(clamped);
try { try {
localStorage.setItem(STORAGE_KEY_NOTIFICATION_VOLUME, String(clamped)); localStorage.setItem(STORAGE_KEY_NOTIFICATION_VOLUME, String(clamped));
} catch {} } catch {}
@@ -91,13 +96,18 @@ export class NotificationAudioService {
*/ */
play(sound: AppSound, volumeOverride?: number): void { play(sound: AppSound, volumeOverride?: number): void {
const cached = this.cache.get(sound); const cached = this.cache.get(sound);
if (!cached) return;
if (!cached)
return;
const vol = volumeOverride ?? this.notificationVolume(); const vol = volumeOverride ?? this.notificationVolume();
if (vol === 0) return; // skip playback when muted
if (vol === 0)
return; // skip playback when muted
// Clone so overlapping plays don't cut each other off. // Clone so overlapping plays don't cut each other off.
const clone = cached.cloneNode(true) as HTMLAudioElement; const clone = cached.cloneNode(true) as HTMLAudioElement;
clone.volume = Math.max(0, Math.min(1, vol)); clone.volume = Math.max(0, Math.min(1, vol));
clone.play().catch(() => { clone.play().catch(() => {
/* swallow autoplay errors */ /* swallow autoplay errors */

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */
import { Injectable, signal, computed } from '@angular/core'; import { Injectable, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, throwError, forkJoin } from 'rxjs'; import { Observable, of, throwError, forkJoin } from 'rxjs';
@@ -27,7 +28,6 @@ export interface ServerEndpoint {
/** localStorage key that persists the user's configured endpoints. */ /** localStorage key that persists the user's configured endpoints. */
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
/** Timeout (ms) for server health-check and alternative-endpoint pings. */ /** Timeout (ms) for server health-check and alternative-endpoint pings. */
const HEALTH_CHECK_TIMEOUT_MS = 5000; const HEALTH_CHECK_TIMEOUT_MS = 5000;
@@ -38,8 +38,10 @@ const HEALTH_CHECK_TIMEOUT_MS = 5000;
function buildDefaultServerUrl(): string { function buildDefaultServerUrl(): string {
if (typeof window !== 'undefined' && window.location) { if (typeof window !== 'undefined' && window.location) {
const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
return `${protocol}://localhost:3001`; return `${protocol}://localhost:3001`;
} }
return 'http://localhost:3001'; return 'http://localhost:3001';
} }
@@ -49,7 +51,7 @@ const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
url: buildDefaultServerUrl(), url: buildDefaultServerUrl(),
isActive: true, isActive: true,
isDefault: true, isDefault: true,
status: 'unknown', status: 'unknown'
}; };
/** /**
@@ -72,7 +74,7 @@ export class ServerDirectoryService {
/** The currently active endpoint, falling back to the first in the list. */ /** The currently active endpoint, falling back to the first in the list. */
readonly activeServer = computed( readonly activeServer = computed(
() => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0], () => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0]
); );
constructor(private readonly http: HttpClient) { constructor(private readonly http: HttpClient) {
@@ -92,8 +94,9 @@ export class ServerDirectoryService {
url: sanitisedUrl, url: sanitisedUrl,
isActive: false, isActive: false,
isDefault: false, isDefault: false,
status: 'unknown', status: 'unknown'
}; };
this._servers.update((endpoints) => [...endpoints, newEndpoint]); this._servers.update((endpoints) => [...endpoints, newEndpoint]);
this.saveEndpoints(); this.saveEndpoints();
} }
@@ -106,17 +109,23 @@ export class ServerDirectoryService {
removeServer(endpointId: string): void { removeServer(endpointId: string): void {
const endpoints = this._servers(); const endpoints = this._servers();
const target = endpoints.find((endpoint) => endpoint.id === endpointId); const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) return;
if (target?.isDefault)
return;
const wasActive = target?.isActive; const wasActive = target?.isActive;
this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId)); this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId));
if (wasActive) { if (wasActive) {
this._servers.update((list) => { this._servers.update((list) => {
if (list.length > 0) list[0].isActive = true; if (list.length > 0)
list[0].isActive = true;
return [...list]; return [...list];
}); });
} }
this.saveEndpoints(); this.saveEndpoints();
} }
@@ -125,8 +134,8 @@ export class ServerDirectoryService {
this._servers.update((endpoints) => this._servers.update((endpoints) =>
endpoints.map((endpoint) => ({ endpoints.map((endpoint) => ({
...endpoint, ...endpoint,
isActive: endpoint.id === endpointId, isActive: endpoint.id === endpointId
})), }))
); );
this.saveEndpoints(); this.saveEndpoints();
} }
@@ -135,12 +144,12 @@ export class ServerDirectoryService {
updateServerStatus( updateServerStatus(
endpointId: string, endpointId: string,
status: ServerEndpoint['status'], status: ServerEndpoint['status'],
latency?: number, latency?: number
): void { ): void {
this._servers.update((endpoints) => this._servers.update((endpoints) =>
endpoints.map((endpoint) => endpoints.map((endpoint) =>
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint, endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint
), )
); );
this.saveEndpoints(); this.saveEndpoints();
} }
@@ -158,7 +167,9 @@ export class ServerDirectoryService {
*/ */
async testServer(endpointId: string): Promise<boolean> { async testServer(endpointId: string): Promise<boolean> {
const endpoint = this._servers().find((entry) => entry.id === endpointId); const endpoint = this._servers().find((entry) => entry.id === endpointId);
if (!endpoint) return false;
if (!endpoint)
return false;
this.updateServerStatus(endpointId, 'checking'); this.updateServerStatus(endpointId, 'checking');
const startTime = Date.now(); const startTime = Date.now();
@@ -166,7 +177,7 @@ export class ServerDirectoryService {
try { try {
const response = await fetch(`${endpoint.url}/api/health`, { const response = await fetch(`${endpoint.url}/api/health`, {
method: 'GET', method: 'GET',
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS)
}); });
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
@@ -174,6 +185,7 @@ export class ServerDirectoryService {
this.updateServerStatus(endpointId, 'online', latency); this.updateServerStatus(endpointId, 'online', latency);
return true; return true;
} }
this.updateServerStatus(endpointId, 'offline'); this.updateServerStatus(endpointId, 'offline');
return false; return false;
} catch { } catch {
@@ -181,14 +193,16 @@ export class ServerDirectoryService {
try { try {
const response = await fetch(`${endpoint.url}/api/servers`, { const response = await fetch(`${endpoint.url}/api/servers`, {
method: 'GET', method: 'GET',
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS)
}); });
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
if (response.ok) { if (response.ok) {
this.updateServerStatus(endpointId, 'online', latency); this.updateServerStatus(endpointId, 'online', latency);
return true; return true;
} }
} catch { /* both checks failed */ } } catch { /* both checks failed */ }
this.updateServerStatus(endpointId, 'offline'); this.updateServerStatus(endpointId, 'offline');
return false; return false;
} }
@@ -197,6 +211,7 @@ export class ServerDirectoryService {
/** Probe all configured endpoints in parallel. */ /** Probe all configured endpoints in parallel. */
async testAllServers(): Promise<void> { async testAllServers(): Promise<void> {
const endpoints = this._servers(); const endpoints = this._servers();
await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id))); await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id)));
} }
@@ -208,10 +223,13 @@ export class ServerDirectoryService {
/** Get the WebSocket URL derived from the active endpoint. */ /** Get the WebSocket URL derived from the active endpoint. */
getWebSocketUrl(): string { getWebSocketUrl(): string {
const active = this.activeServer(); const active = this.activeServer();
if (!active) { if (!active) {
const protocol = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws'; const protocol = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
return `${protocol}://localhost:3001`; return `${protocol}://localhost:3001`;
} }
return active.url.replace(/^http/, 'ws'); return active.url.replace(/^http/, 'ws');
} }
@@ -224,6 +242,7 @@ export class ServerDirectoryService {
if (this.shouldSearchAllServers) { if (this.shouldSearchAllServers) {
return this.searchAllEndpoints(query); return this.searchAllEndpoints(query);
} }
return this.searchSingleEndpoint(query, this.buildApiBaseUrl()); return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
} }
@@ -232,6 +251,7 @@ export class ServerDirectoryService {
if (this.shouldSearchAllServers) { if (this.shouldSearchAllServers) {
return this.getAllServersFromAllEndpoints(); return this.getAllServersFromAllEndpoints();
} }
return this.http return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
.pipe( .pipe(
@@ -239,7 +259,7 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to get servers:', error); console.error('Failed to get servers:', error);
return of([]); return of([]);
}), })
); );
} }
@@ -251,13 +271,13 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to get server:', error); console.error('Failed to get server:', error);
return of(null); return of(null);
}), })
); );
} }
/** Register a new server listing in the directory. */ /** Register a new server listing in the directory. */
registerServer( registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string }, server: Omit<ServerInfo, 'createdAt'> & { id?: string }
): Observable<ServerInfo> { ): Observable<ServerInfo> {
return this.http return this.http
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server) .post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
@@ -265,14 +285,14 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to register server:', error); console.error('Failed to register server:', error);
return throwError(() => error); return throwError(() => error);
}), })
); );
} }
/** Update an existing server listing. */ /** Update an existing server listing. */
updateServer( updateServer(
serverId: string, serverId: string,
updates: Partial<ServerInfo>, updates: Partial<ServerInfo>
): Observable<ServerInfo> { ): Observable<ServerInfo> {
return this.http return this.http
.patch<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates) .patch<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
@@ -280,7 +300,7 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to update server:', error); console.error('Failed to update server:', error);
return throwError(() => error); return throwError(() => error);
}), })
); );
} }
@@ -292,7 +312,7 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to unregister server:', error); console.error('Failed to unregister server:', error);
return throwError(() => error); return throwError(() => error);
}), })
); );
} }
@@ -304,24 +324,24 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to get server users:', error); console.error('Failed to get server users:', error);
return of([]); return of([]);
}), })
); );
} }
/** Send a join request for a server and receive the signaling URL. */ /** Send a join request for a server and receive the signaling URL. */
requestJoin( requestJoin(
request: JoinRequest, request: JoinRequest
): Observable<{ success: boolean; signalingUrl?: string }> { ): Observable<{ success: boolean; signalingUrl?: string }> {
return this.http return this.http
.post<{ success: boolean; signalingUrl?: string }>( .post<{ success: boolean; signalingUrl?: string }>(
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`, `${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
request, request
) )
.pipe( .pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to send join request:', error); console.error('Failed to send join request:', error);
return throwError(() => error); return throwError(() => error);
}), })
); );
} }
@@ -333,7 +353,7 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to notify leave:', error); console.error('Failed to notify leave:', error);
return of(undefined); return of(undefined);
}), })
); );
} }
@@ -345,7 +365,7 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to update user count:', error); console.error('Failed to update user count:', error);
return of(undefined); return of(undefined);
}), })
); );
} }
@@ -357,7 +377,7 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to send heartbeat:', error); console.error('Failed to send heartbeat:', error);
return of(undefined); return of(undefined);
}), })
); );
} }
@@ -368,19 +388,24 @@ export class ServerDirectoryService {
private buildApiBaseUrl(): string { private buildApiBaseUrl(): string {
const active = this.activeServer(); const active = this.activeServer();
const rawUrl = active ? active.url : buildDefaultServerUrl(); const rawUrl = active ? active.url : buildDefaultServerUrl();
let base = rawUrl.replace(/\/+$/, ''); let base = rawUrl.replace(/\/+$/, '');
if (base.toLowerCase().endsWith('/api')) { if (base.toLowerCase().endsWith('/api')) {
base = base.slice(0, -4); base = base.slice(0, -4);
} }
return `${base}/api`; return `${base}/api`;
} }
/** Strip trailing slashes and `/api` suffix from a URL. */ /** Strip trailing slashes and `/api` suffix from a URL. */
private sanitiseUrl(rawUrl: string): string { private sanitiseUrl(rawUrl: string): string {
let cleaned = rawUrl.trim().replace(/\/+$/, ''); let cleaned = rawUrl.trim().replace(/\/+$/, '');
if (cleaned.toLowerCase().endsWith('/api')) { if (cleaned.toLowerCase().endsWith('/api')) {
cleaned = cleaned.slice(0, -4); cleaned = cleaned.slice(0, -4);
} }
return cleaned; return cleaned;
} }
@@ -389,18 +414,21 @@ export class ServerDirectoryService {
* response shapes from the directory API. * response shapes from the directory API.
*/ */
private unwrapServersResponse( private unwrapServersResponse(
response: { servers: ServerInfo[]; total: number } | ServerInfo[], response: { servers: ServerInfo[]; total: number } | ServerInfo[]
): ServerInfo[] { ): ServerInfo[] {
if (Array.isArray(response)) return response; if (Array.isArray(response))
return response;
return response.servers ?? []; return response.servers ?? [];
} }
/** Search a single endpoint for servers matching a query. */ /** Search a single endpoint for servers matching a query. */
private searchSingleEndpoint( private searchSingleEndpoint(
query: string, query: string,
apiBaseUrl: string, apiBaseUrl: string
): Observable<ServerInfo[]> { ): Observable<ServerInfo[]> {
const params = new HttpParams().set('q', query); const params = new HttpParams().set('q', query);
return this.http return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }) .get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
.pipe( .pipe(
@@ -408,14 +436,14 @@ export class ServerDirectoryService {
catchError((error) => { catchError((error) => {
console.error('Failed to search servers:', error); console.error('Failed to search servers:', error);
return of([]); return of([]);
}), })
); );
} }
/** Fan-out search across all non-offline endpoints, deduplicating results. */ /** Fan-out search across all non-offline endpoints, deduplicating results. */
private searchAllEndpoints(query: string): Observable<ServerInfo[]> { private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
const onlineEndpoints = this._servers().filter( const onlineEndpoints = this._servers().filter(
(endpoint) => endpoint.status !== 'offline', (endpoint) => endpoint.status !== 'offline'
); );
if (onlineEndpoints.length === 0) { if (onlineEndpoints.length === 0) {
@@ -428,22 +456,22 @@ export class ServerDirectoryService {
results.map((server) => ({ results.map((server) => ({
...server, ...server,
sourceId: endpoint.id, sourceId: endpoint.id,
sourceName: endpoint.name, sourceName: endpoint.name
})), }))
), )
), )
); );
return forkJoin(requests).pipe( return forkJoin(requests).pipe(
map((resultArrays) => resultArrays.flat()), map((resultArrays) => resultArrays.flat()),
map((servers) => this.deduplicateById(servers)), map((servers) => this.deduplicateById(servers))
); );
} }
/** Retrieve all servers from all non-offline endpoints. */ /** Retrieve all servers from all non-offline endpoints. */
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> { private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const onlineEndpoints = this._servers().filter( const onlineEndpoints = this._servers().filter(
(endpoint) => endpoint.status !== 'offline', (endpoint) => endpoint.status !== 'offline'
); );
if (onlineEndpoints.length === 0) { if (onlineEndpoints.length === 0) {
@@ -451,7 +479,7 @@ export class ServerDirectoryService {
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
.pipe( .pipe(
map((response) => this.unwrapServersResponse(response)), map((response) => this.unwrapServersResponse(response)),
catchError(() => of([])), catchError(() => of([]))
); );
} }
@@ -461,14 +489,15 @@ export class ServerDirectoryService {
.pipe( .pipe(
map((response) => { map((response) => {
const results = this.unwrapServersResponse(response); const results = this.unwrapServersResponse(response);
return results.map((server) => ({ return results.map((server) => ({
...server, ...server,
sourceId: endpoint.id, sourceId: endpoint.id,
sourceName: endpoint.name, sourceName: endpoint.name
})); }));
}), }),
catchError(() => of([] as ServerInfo[])), catchError(() => of([] as ServerInfo[]))
), )
); );
return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat())); return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat()));
@@ -477,8 +506,11 @@ export class ServerDirectoryService {
/** Remove duplicate servers (by `id`), keeping the first occurrence. */ /** Remove duplicate servers (by `id`), keeping the first occurrence. */
private deduplicateById<T extends { id: string }>(items: T[]): T[] { private deduplicateById<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>(); const seen = new Set<string>();
return items.filter((item) => { return items.filter((item) => {
if (seen.has(item.id)) return false; if (seen.has(item.id))
return false;
seen.add(item.id); seen.add(item.id);
return true; return true;
}); });
@@ -487,6 +519,7 @@ export class ServerDirectoryService {
/** Load endpoints from localStorage, migrating protocol if needed. */ /** Load endpoints from localStorage, migrating protocol if needed. */
private loadEndpoints(): void { private loadEndpoints(): void {
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
if (!stored) { if (!stored) {
this.initialiseDefaultEndpoint(); this.initialiseDefaultEndpoint();
return; return;
@@ -510,6 +543,7 @@ export class ServerDirectoryService {
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) { if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) }; return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) };
} }
return endpoint; return endpoint;
}); });
@@ -523,6 +557,7 @@ export class ServerDirectoryService {
/** Create and persist the built-in default endpoint. */ /** Create and persist the built-in default endpoint. */
private initialiseDefaultEndpoint(): void { private initialiseDefaultEndpoint(): void {
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() }; const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() };
this._servers.set([defaultEndpoint]); this._servers.set([defaultEndpoint]);
this.saveEndpoints(); this.saveEndpoints();
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal, computed } from '@angular/core'; import { Injectable, signal, computed } from '@angular/core';
/** Default timeout (ms) for the NTP-style HTTP sync request. */ /** Default timeout (ms) for the NTP-style HTTP sync request. */
@@ -43,6 +44,7 @@ export class TimeSyncService {
*/ */
setFromServerTime(serverTime: number, receiveTimestamp?: number): void { setFromServerTime(serverTime: number, receiveTimestamp?: number): void {
const observedAt = receiveTimestamp ?? Date.now(); const observedAt = receiveTimestamp ?? Date.now();
this._offset.set(serverTime - observedAt); this._offset.set(serverTime - observedAt);
this.lastSyncTimestamp = Date.now(); this.lastSyncTimestamp = Date.now();
} }
@@ -65,21 +67,21 @@ export class TimeSyncService {
*/ */
async syncWithEndpoint( async syncWithEndpoint(
baseApiUrl: string, baseApiUrl: string,
timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS, timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS
): Promise<void> { ): Promise<void> {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const clientSendTime = Date.now(); const clientSendTime = Date.now();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(`${baseApiUrl}/time`, { const response = await fetch(`${baseApiUrl}/time`, {
signal: controller.signal, signal: controller.signal
}); });
const clientReceiveTime = Date.now(); const clientReceiveTime = Date.now();
clearTimeout(timer); clearTimeout(timer);
if (!response.ok) return; if (!response.ok)
return;
const data = await response.json(); const data = await response.json();
const serverNow = Number(data?.now) || Date.now(); const serverNow = Number(data?.now) || Date.now();

View File

@@ -19,13 +19,12 @@
import { Injectable, signal, computed, inject, OnDestroy, Signal } from '@angular/core'; import { Injectable, signal, computed, inject, OnDestroy, Signal } from '@angular/core';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { WebRTCService } from './webrtc.service'; import { WebRTCService } from './webrtc.service';
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */
/** RMS volume threshold (01) above which a user counts as "speaking". */ /** RMS volume threshold (01) above which a user counts as "speaking". */
const SPEAKING_THRESHOLD = 0.015; const SPEAKING_THRESHOLD = 0.015;
/** How many consecutive silent frames before we flip speaking → false. */ /** How many consecutive silent frames before we flip speaking → false. */
const SILENT_FRAME_GRACE = 8; const SILENT_FRAME_GRACE = 8;
/** FFT size for the AnalyserNode (smaller = cheaper). */ /** FFT size for the AnalyserNode (smaller = cheaper). */
const FFT_SIZE = 256; const FFT_SIZE = 256;
@@ -73,13 +72,13 @@ export class VoiceActivityService implements OnDestroy {
this.subs.push( this.subs.push(
this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => { this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => {
this.trackStream(peerId, stream); this.trackStream(peerId, stream);
}), })
); );
this.subs.push( this.subs.push(
this.webrtc.onPeerDisconnected.subscribe((peerId) => { this.webrtc.onPeerDisconnected.subscribe((peerId) => {
this.untrackStream(peerId); this.untrackStream(peerId);
}), })
); );
} }
@@ -114,7 +113,9 @@ export class VoiceActivityService implements OnDestroy {
*/ */
isSpeaking(userId: string): Signal<boolean> { isSpeaking(userId: string): Signal<boolean> {
const entry = this.tracked.get(userId); const entry = this.tracked.get(userId);
if (entry) return entry.speakingSignal.asReadonly();
if (entry)
return entry.speakingSignal.asReadonly();
// Return a computed that re-checks the map so it becomes live // Return a computed that re-checks the map so it becomes live
// once the stream is tracked. // once the stream is tracked.
@@ -127,7 +128,10 @@ export class VoiceActivityService implements OnDestroy {
*/ */
volume(userId: string): Signal<number> { volume(userId: string): Signal<number> {
const entry = this.tracked.get(userId); const entry = this.tracked.get(userId);
if (entry) return entry.volumeSignal.asReadonly();
if (entry)
return entry.volumeSignal.asReadonly();
return computed(() => 0); return computed(() => 0);
} }
@@ -141,14 +145,18 @@ export class VoiceActivityService implements OnDestroy {
trackStream(id: string, stream: MediaStream): void { trackStream(id: string, stream: MediaStream): void {
// If we already track this exact stream, skip. // If we already track this exact stream, skip.
const existing = this.tracked.get(id); const existing = this.tracked.get(id);
if (existing && existing.stream === stream) return;
if (existing && existing.stream === stream)
return;
// Clean up any previous entry for this id. // Clean up any previous entry for this id.
if (existing) this.disposeEntry(existing); if (existing)
this.disposeEntry(existing);
const ctx = new AudioContext(); const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream); const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser(); const analyser = ctx.createAnalyser();
analyser.fftSize = FFT_SIZE; analyser.fftSize = FFT_SIZE;
source.connect(analyser); source.connect(analyser);
@@ -167,7 +175,7 @@ export class VoiceActivityService implements OnDestroy {
volumeSignal, volumeSignal,
speakingSignal, speakingSignal,
silentFrames: 0, silentFrames: 0,
stream, stream
}); });
// Ensure the poll loop is running. // Ensure the poll loop is running.
@@ -177,19 +185,25 @@ export class VoiceActivityService implements OnDestroy {
/** Stop tracking and dispose resources for a given ID. */ /** Stop tracking and dispose resources for a given ID. */
untrackStream(id: string): void { untrackStream(id: string): void {
const entry = this.tracked.get(id); const entry = this.tracked.get(id);
if (!entry) return;
if (!entry)
return;
this.disposeEntry(entry); this.disposeEntry(entry);
this.tracked.delete(id); this.tracked.delete(id);
this.publishSpeakingMap(); this.publishSpeakingMap();
// Stop polling when nothing is tracked. // Stop polling when nothing is tracked.
if (this.tracked.size === 0) this.stopPolling(); if (this.tracked.size === 0)
this.stopPolling();
} }
// ── Polling loop ──────────────────────────────────────────────── // ── Polling loop ────────────────────────────────────────────────
private ensurePolling(): void { private ensurePolling(): void {
if (this.animFrameId !== null) return; if (this.animFrameId !== null)
return;
this.poll(); this.poll();
} }
@@ -214,23 +228,29 @@ export class VoiceActivityService implements OnDestroy {
// Compute RMS volume from time-domain data (values 0255, centred at 128). // Compute RMS volume from time-domain data (values 0255, centred at 128).
let sumSquares = 0; let sumSquares = 0;
for (let i = 0; i < dataArray.length; i++) { for (let i = 0; i < dataArray.length; i++) {
const normalised = (dataArray[i] - 128) / 128; const normalised = (dataArray[i] - 128) / 128;
sumSquares += normalised * normalised; sumSquares += normalised * normalised;
} }
const rms = Math.sqrt(sumSquares / dataArray.length); const rms = Math.sqrt(sumSquares / dataArray.length);
volumeSignal.set(rms); volumeSignal.set(rms);
const wasSpeaking = speakingSignal(); const wasSpeaking = speakingSignal();
if (rms >= SPEAKING_THRESHOLD) { if (rms >= SPEAKING_THRESHOLD) {
entry.silentFrames = 0; entry.silentFrames = 0;
if (!wasSpeaking) { if (!wasSpeaking) {
speakingSignal.set(true); speakingSignal.set(true);
mapDirty = true; mapDirty = true;
} }
} else { } else {
entry.silentFrames++; entry.silentFrames++;
if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) { if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) {
speakingSignal.set(false); speakingSignal.set(false);
mapDirty = true; mapDirty = true;
@@ -238,7 +258,8 @@ export class VoiceActivityService implements OnDestroy {
} }
}); });
if (mapDirty) this.publishSpeakingMap(); if (mapDirty)
this.publishSpeakingMap();
this.animFrameId = requestAnimationFrame(this.poll); this.animFrameId = requestAnimationFrame(this.poll);
}; };
@@ -246,6 +267,7 @@ export class VoiceActivityService implements OnDestroy {
/** Rebuild the public speaking-map signal from current entries. */ /** Rebuild the public speaking-map signal from current entries. */
private publishSpeakingMap(): void { private publishSpeakingMap(): void {
const map = new Map<string, boolean>(); const map = new Map<string, boolean>();
this.tracked.forEach((entry, id) => { this.tracked.forEach((entry, id) => {
map.set(id, entry.speakingSignal()); map.set(id, entry.speakingSignal());
}); });
@@ -256,6 +278,7 @@ export class VoiceActivityService implements OnDestroy {
private disposeEntry(entry: TrackedStream): void { private disposeEntry(entry: TrackedStream): void {
try { entry.source.disconnect(); } catch { /* already disconnected */ } try { entry.source.disconnect(); } catch { /* already disconnected */ }
try { entry.ctx.close(); } catch { /* already closed */ } try { entry.ctx.close(); } catch { /* already closed */ }
} }

View File

@@ -25,11 +25,12 @@
* *
* ═══════════════════════════════════════════════════════════════════ * ═══════════════════════════════════════════════════════════════════
*/ */
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal, computed, OnDestroy } from '@angular/core'; import { Injectable, signal, computed, OnDestroy } from '@angular/core';
import { import {
VoiceLevelingManager, VoiceLevelingManager,
VoiceLevelingSettings, VoiceLevelingSettings,
DEFAULT_VOICE_LEVELING_SETTINGS, DEFAULT_VOICE_LEVELING_SETTINGS
} from './webrtc/voice-leveling.manager'; } from './webrtc/voice-leveling.manager';
import { WebRTCLogger } from './webrtc/webrtc-logger'; import { WebRTCLogger } from './webrtc/webrtc-logger';
import { STORAGE_KEY_VOICE_LEVELING_SETTINGS } from '../constants'; import { STORAGE_KEY_VOICE_LEVELING_SETTINGS } from '../constants';
@@ -71,10 +72,11 @@ export class VoiceLevelingService implements OnDestroy {
/* ── Enabled-change callbacks ────────────────────────────────── */ /* ── Enabled-change callbacks ────────────────────────────────── */
private _enabledChangeCallbacks: Array<(enabled: boolean) => void> = []; private _enabledChangeCallbacks: ((enabled: boolean) => void)[] = [];
constructor() { constructor() {
const logger = new WebRTCLogger(/* debugEnabled */ false); const logger = new WebRTCLogger(/* debugEnabled */ false);
this.manager = new VoiceLevelingManager(logger); this.manager = new VoiceLevelingManager(logger);
// Restore persisted settings // Restore persisted settings
@@ -101,6 +103,7 @@ export class VoiceLevelingService implements OnDestroy {
/** Set the target loudness in dBFS (30 to 12). */ /** Set the target loudness in dBFS (30 to 12). */
setTargetDbfs(value: number): void { setTargetDbfs(value: number): void {
const clamped = Math.max(-30, Math.min(-12, value)); const clamped = Math.max(-30, Math.min(-12, value));
this._targetDbfs.set(clamped); this._targetDbfs.set(clamped);
this._pushAndPersist({ targetDbfs: clamped }); this._pushAndPersist({ targetDbfs: clamped });
} }
@@ -114,6 +117,7 @@ export class VoiceLevelingService implements OnDestroy {
/** Set the maximum gain boost in dB (3 to 20). */ /** Set the maximum gain boost in dB (3 to 20). */
setMaxGainDb(value: number): void { setMaxGainDb(value: number): void {
const clamped = Math.max(3, Math.min(20, value)); const clamped = Math.max(3, Math.min(20, value));
this._maxGainDb.set(clamped); this._maxGainDb.set(clamped);
this._pushAndPersist({ maxGainDb: clamped }); this._pushAndPersist({ maxGainDb: clamped });
} }
@@ -186,9 +190,10 @@ export class VoiceLevelingService implements OnDestroy {
*/ */
onEnabledChange(callback: (enabled: boolean) => void): () => void { onEnabledChange(callback: (enabled: boolean) => void): () => void {
this._enabledChangeCallbacks.push(callback); this._enabledChangeCallbacks.push(callback);
return () => { return () => {
this._enabledChangeCallbacks = this._enabledChangeCallbacks.filter( this._enabledChangeCallbacks = this._enabledChangeCallbacks.filter(
(cb) => cb !== callback, (cb) => cb !== callback
); );
}; };
} }
@@ -210,11 +215,12 @@ export class VoiceLevelingService implements OnDestroy {
strength: this._strength(), strength: this._strength(),
maxGainDb: this._maxGainDb(), maxGainDb: this._maxGainDb(),
speed: this._speed(), speed: this._speed(),
noiseGate: this._noiseGate(), noiseGate: this._noiseGate()
}; };
localStorage.setItem( localStorage.setItem(
STORAGE_KEY_VOICE_LEVELING_SETTINGS, STORAGE_KEY_VOICE_LEVELING_SETTINGS,
JSON.stringify(settings), JSON.stringify(settings)
); );
} catch { /* localStorage unavailable — ignore */ } } catch { /* localStorage unavailable — ignore */ }
} }
@@ -223,19 +229,31 @@ export class VoiceLevelingService implements OnDestroy {
private _loadSettings(): void { private _loadSettings(): void {
try { try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_LEVELING_SETTINGS); const raw = localStorage.getItem(STORAGE_KEY_VOICE_LEVELING_SETTINGS);
if (!raw) return;
if (!raw)
return;
const saved = JSON.parse(raw) as Partial<VoiceLevelingSettings>; const saved = JSON.parse(raw) as Partial<VoiceLevelingSettings>;
if (typeof saved.enabled === 'boolean') this._enabled.set(saved.enabled); if (typeof saved.enabled === 'boolean')
if (typeof saved.targetDbfs === 'number') this._targetDbfs.set(saved.targetDbfs); this._enabled.set(saved.enabled);
if (typeof saved.targetDbfs === 'number')
this._targetDbfs.set(saved.targetDbfs);
if (saved.strength === 'low' || saved.strength === 'medium' || saved.strength === 'high') { if (saved.strength === 'low' || saved.strength === 'medium' || saved.strength === 'high') {
this._strength.set(saved.strength); this._strength.set(saved.strength);
} }
if (typeof saved.maxGainDb === 'number') this._maxGainDb.set(saved.maxGainDb);
if (typeof saved.maxGainDb === 'number')
this._maxGainDb.set(saved.maxGainDb);
if (saved.speed === 'slow' || saved.speed === 'medium' || saved.speed === 'fast') { if (saved.speed === 'slow' || saved.speed === 'medium' || saved.speed === 'fast') {
this._speed.set(saved.speed); this._speed.set(saved.speed);
} }
if (typeof saved.noiseGate === 'boolean') this._noiseGate.set(saved.noiseGate);
if (typeof saved.noiseGate === 'boolean')
this._noiseGate.set(saved.noiseGate);
// Push the restored settings to the manager // Push the restored settings to the manager
this.manager.updateSettings({ this.manager.updateSettings({
@@ -244,7 +262,7 @@ export class VoiceLevelingService implements OnDestroy {
strength: this._strength(), strength: this._strength(),
maxGainDb: this._maxGainDb(), maxGainDb: this._maxGainDb(),
speed: this._speed(), speed: this._speed(),
noiseGate: this._noiseGate(), noiseGate: this._noiseGate()
}); });
} catch { /* corrupted data — use defaults */ } } catch { /* corrupted data — use defaults */ }
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
import { Injectable, signal, computed, inject } from '@angular/core'; import { Injectable, signal, computed, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -56,7 +57,7 @@ export class VoiceSessionService {
* a different server. * a different server.
*/ */
readonly showFloatingControls = computed( readonly showFloatingControls = computed(
() => this._voiceSession() !== null && !this._isViewingVoiceServer(), () => this._voiceSession() !== null && !this._isViewingVoiceServer()
); );
/** /**
@@ -97,10 +98,12 @@ export class VoiceSessionService {
*/ */
checkCurrentRoute(currentServerId: string | null): void { checkCurrentRoute(currentServerId: string | null): void {
const session = this._voiceSession(); const session = this._voiceSession();
if (!session) { if (!session) {
this._isViewingVoiceServer.set(true); this._isViewingVoiceServer.set(true);
return; return;
} }
this._isViewingVoiceServer.set(currentServerId === session.serverId); this._isViewingVoiceServer.set(currentServerId === session.serverId);
} }
@@ -110,7 +113,9 @@ export class VoiceSessionService {
*/ */
navigateToVoiceServer(): void { navigateToVoiceServer(): void {
const session = this._voiceSession(); const session = this._voiceSession();
if (!session) return;
if (!session)
return;
this.store.dispatch( this.store.dispatch(
RoomsActions.viewServer({ RoomsActions.viewServer({
@@ -123,9 +128,9 @@ export class VoiceSessionService {
createdAt: 0, createdAt: 0,
userCount: 0, userCount: 0,
maxUsers: 50, maxUsers: 50,
icon: session.serverIcon, icon: session.serverIcon
} as any, } as any
}), })
); );
this._isViewingVoiceServer.set(true); this._isViewingVoiceServer.set(true);
} }

View File

@@ -11,6 +11,7 @@
* This file wires them together and exposes a public API that is * This file wires them together and exposes a public API that is
* identical to the old monolithic service so consumers don't change. * identical to the old monolithic service so consumers don't change.
*/ */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */
import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core'; import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -43,11 +44,11 @@ import {
SIGNALING_TYPE_USER_LEFT, SIGNALING_TYPE_USER_LEFT,
DEFAULT_DISPLAY_NAME, DEFAULT_DISPLAY_NAME,
P2P_TYPE_VOICE_STATE, P2P_TYPE_VOICE_STATE,
P2P_TYPE_SCREEN_STATE, P2P_TYPE_SCREEN_STATE
} from './webrtc'; } from './webrtc';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root'
}) })
export class WebRTCService implements OnDestroy { export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService); private readonly timeSync = inject(TimeSyncService);
@@ -97,8 +98,12 @@ export class WebRTCService implements OnDestroy {
readonly hasConnectionError = computed(() => this._hasConnectionError()); readonly hasConnectionError = computed(() => this._hasConnectionError());
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage()); readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
readonly shouldShowConnectionError = computed(() => { readonly shouldShowConnectionError = computed(() => {
if (!this._hasConnectionError()) return false; if (!this._hasConnectionError())
if (this._isVoiceConnected() && this._connectedPeers().length > 0) return false; return false;
if (this._isVoiceConnected() && this._connectedPeers().length > 0)
return false;
return true; return true;
}); });
/** Per-peer latency map (ms). Read via `peerLatencies()`. */ /** Per-peer latency map (ms). Read via `peerLatencies()`. */
@@ -135,7 +140,7 @@ export class WebRTCService implements OnDestroy {
this.logger, this.logger,
() => this.lastIdentifyCredentials, () => this.lastIdentifyCredentials,
() => this.lastJoinedServer, () => this.lastJoinedServer,
() => this.memberServerIds, () => this.memberServerIds
); );
this.peerManager = new PeerConnectionManager(this.logger, null!); this.peerManager = new PeerConnectionManager(this.logger, null!);
@@ -152,7 +157,7 @@ export class WebRTCService implements OnDestroy {
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(), getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials, getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials,
getLocalPeerId: (): string => this._localPeerId(), getLocalPeerId: (): string => this._localPeerId(),
isScreenSharingActive: (): boolean => this._isScreenSharing(), isScreenSharingActive: (): boolean => this._isScreenSharing()
}); });
this.mediaManager.setCallbacks({ this.mediaManager.setCallbacks({
@@ -162,7 +167,7 @@ export class WebRTCService implements OnDestroy {
broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event), broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event),
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(), getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
getIdentifyDisplayName: (): string => getIdentifyDisplayName: (): string =>
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME, this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
}); });
this.screenShareManager.setCallbacks({ this.screenShareManager.setCallbacks({
@@ -170,7 +175,7 @@ export class WebRTCService implements OnDestroy {
this.peerManager.activePeerConnections, this.peerManager.activePeerConnections,
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId), renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(), broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates()
}); });
this.wireManagerEvents(); this.wireManagerEvents();
@@ -180,7 +185,10 @@ export class WebRTCService implements OnDestroy {
// Signaling → connection status // Signaling → connection status
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => { this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
this._isSignalingConnected.set(connected); this._isSignalingConnected.set(connected);
if (connected) this._hasEverConnected.set(true);
if (connected)
this._hasEverConnected.set(true);
this._hasConnectionError.set(!connected); this._hasConnectionError.set(!connected);
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null)); this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
}); });
@@ -193,7 +201,7 @@ export class WebRTCService implements OnDestroy {
// Peer manager → connected peers signal // Peer manager → connected peers signal
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
this._connectedPeers.set(peers), this._connectedPeers.set(peers)
); );
// Media manager → voice connected signal // Media manager → voice connected signal
@@ -204,6 +212,7 @@ export class WebRTCService implements OnDestroy {
// Peer manager → latency updates // Peer manager → latency updates
this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => { this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => {
const next = new Map(this.peerManager.peerLatencies); const next = new Map(this.peerManager.peerLatencies);
this._peerLatencies.set(next); this._peerLatencies.set(next);
}); });
} }
@@ -215,23 +224,27 @@ export class WebRTCService implements OnDestroy {
switch (message.type) { switch (message.type) {
case SIGNALING_TYPE_CONNECTED: case SIGNALING_TYPE_CONNECTED:
this.logger.info('Server connected', { oderId: message.oderId }); this.logger.info('Server connected', { oderId: message.oderId });
if (typeof message.serverTime === 'number') { if (typeof message.serverTime === 'number') {
this.timeSync.setFromServerTime(message.serverTime); this.timeSync.setFromServerTime(message.serverTime);
} }
break; break;
case SIGNALING_TYPE_SERVER_USERS: { case SIGNALING_TYPE_SERVER_USERS: {
this.logger.info('Server users', { this.logger.info('Server users', {
count: Array.isArray(message.users) ? message.users.length : 0, count: Array.isArray(message.users) ? message.users.length : 0,
serverId: message.serverId, serverId: message.serverId
}); });
if (message.users && Array.isArray(message.users)) { if (message.users && Array.isArray(message.users)) {
message.users.forEach((user: { oderId: string; displayName: string }) => { message.users.forEach((user: { oderId: string; displayName: string }) => {
if (!user.oderId) return; if (!user.oderId)
return;
const existing = this.peerManager.activePeerConnections.get(user.oderId); const existing = this.peerManager.activePeerConnections.get(user.oderId);
const healthy = this.isPeerHealthy(existing); const healthy = this.isPeerHealthy(existing);
if (existing && !healthy) { if (existing && !healthy) {
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId }); this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
this.peerManager.removePeer(user.oderId); this.peerManager.removePeer(user.oderId);
@@ -240,23 +253,25 @@ export class WebRTCService implements OnDestroy {
if (!healthy) { if (!healthy) {
this.logger.info('Create peer connection to existing user', { this.logger.info('Create peer connection to existing user', {
oderId: user.oderId, oderId: user.oderId,
serverId: message.serverId, serverId: message.serverId
}); });
this.peerManager.createPeerConnection(user.oderId, true); this.peerManager.createPeerConnection(user.oderId, true);
this.peerManager.createAndSendOffer(user.oderId); this.peerManager.createAndSendOffer(user.oderId);
if (message.serverId) { if (message.serverId) {
this.peerServerMap.set(user.oderId, message.serverId); this.peerServerMap.set(user.oderId, message.serverId);
} }
} }
}); });
} }
break; break;
} }
case SIGNALING_TYPE_USER_JOINED: case SIGNALING_TYPE_USER_JOINED:
this.logger.info('User joined', { this.logger.info('User joined', {
displayName: message.displayName, displayName: message.displayName,
oderId: message.oderId, oderId: message.oderId
}); });
break; break;
@@ -264,35 +279,42 @@ export class WebRTCService implements OnDestroy {
this.logger.info('User left', { this.logger.info('User left', {
displayName: message.displayName, displayName: message.displayName,
oderId: message.oderId, oderId: message.oderId,
serverId: message.serverId, serverId: message.serverId
}); });
if (message.oderId) { if (message.oderId) {
this.peerManager.removePeer(message.oderId); this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId); this.peerServerMap.delete(message.oderId);
} }
break; break;
case SIGNALING_TYPE_OFFER: case SIGNALING_TYPE_OFFER:
if (message.fromUserId && message.payload?.sdp) { if (message.fromUserId && message.payload?.sdp) {
// Track inbound peer as belonging to our effective server // Track inbound peer as belonging to our effective server
const offerEffectiveServer = this.voiceServerId || this.activeServerId; const offerEffectiveServer = this.voiceServerId || this.activeServerId;
if (offerEffectiveServer && !this.peerServerMap.has(message.fromUserId)) { if (offerEffectiveServer && !this.peerServerMap.has(message.fromUserId)) {
this.peerServerMap.set(message.fromUserId, offerEffectiveServer); this.peerServerMap.set(message.fromUserId, offerEffectiveServer);
} }
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp); this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
} }
break; break;
case SIGNALING_TYPE_ANSWER: case SIGNALING_TYPE_ANSWER:
if (message.fromUserId && message.payload?.sdp) { if (message.fromUserId && message.payload?.sdp) {
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp); this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp);
} }
break; break;
case SIGNALING_TYPE_ICE_CANDIDATE: case SIGNALING_TYPE_ICE_CANDIDATE:
if (message.fromUserId && message.payload?.candidate) { if (message.fromUserId && message.payload?.candidate) {
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate); this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate);
} }
break; break;
} }
} }
@@ -307,11 +329,13 @@ export class WebRTCService implements OnDestroy {
*/ */
private closePeersNotInServer(serverId: string): void { private closePeersNotInServer(serverId: string): void {
const peersToClose: string[] = []; const peersToClose: string[] = [];
this.peerServerMap.forEach((peerServerId, peerId) => { this.peerServerMap.forEach((peerServerId, peerId) => {
if (peerServerId !== serverId) { if (peerServerId !== serverId) {
peersToClose.push(peerId); peersToClose.push(peerId);
} }
}); });
for (const peerId of peersToClose) { for (const peerId of peersToClose) {
this.logger.info('Closing peer from different server', { peerId, currentServer: serverId }); this.logger.info('Closing peer from different server', { peerId, currentServer: serverId });
this.peerManager.removePeer(peerId); this.peerManager.removePeer(peerId);
@@ -326,7 +350,7 @@ export class WebRTCService implements OnDestroy {
isDeafened: this._isDeafened(), isDeafened: this._isDeafened(),
isScreenSharing: this._isScreenSharing(), isScreenSharing: this._isScreenSharing(),
roomId: this.mediaManager.getCurrentVoiceRoomId(), roomId: this.mediaManager.getCurrentVoiceRoomId(),
serverId: this.mediaManager.getCurrentVoiceServerId(), serverId: this.mediaManager.getCurrentVoiceServerId()
}; };
} }
@@ -421,7 +445,7 @@ export class WebRTCService implements OnDestroy {
this.logger.info('Viewed server (already joined)', { this.logger.info('Viewed server (already joined)', {
serverId, serverId,
userId, userId,
voiceConnected: this._isVoiceConnected(), voiceConnected: this._isVoiceConnected()
}); });
} else { } else {
this.memberServerIds.add(serverId); this.memberServerIds.add(serverId);
@@ -429,7 +453,7 @@ export class WebRTCService implements OnDestroy {
this.logger.info('Joined new server via switch', { this.logger.info('Joined new server via switch', {
serverId, serverId,
userId, userId,
voiceConnected: this._isVoiceConnected(), voiceConnected: this._isVoiceConnected()
}); });
} }
} }
@@ -447,9 +471,11 @@ export class WebRTCService implements OnDestroy {
this.memberServerIds.delete(serverId); this.memberServerIds.delete(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId });
this.logger.info('Left server', { serverId }); this.logger.info('Left server', { serverId });
if (this.memberServerIds.size === 0) { if (this.memberServerIds.size === 0) {
this.fullCleanup(); this.fullCleanup();
} }
return; return;
} }
@@ -534,6 +560,7 @@ export class WebRTCService implements OnDestroy {
*/ */
async enableVoice(): Promise<MediaStream> { async enableVoice(): Promise<MediaStream> {
const stream = await this.mediaManager.enableVoice(); const stream = await this.mediaManager.enableVoice();
this.syncMediaSignals(); this.syncMediaSignals();
return stream; return stream;
} }
@@ -630,6 +657,7 @@ export class WebRTCService implements OnDestroy {
if (serverId) { if (serverId) {
this.voiceServerId = serverId; this.voiceServerId = serverId;
} }
this.mediaManager.startVoiceHeartbeat(roomId, serverId); this.mediaManager.startVoiceHeartbeat(roomId, serverId);
} }
@@ -644,8 +672,9 @@ export class WebRTCService implements OnDestroy {
* @param includeAudio - Whether to capture and mix system audio. * @param includeAudio - Whether to capture and mix system audio.
* @returns The screen-capture {@link MediaStream}. * @returns The screen-capture {@link MediaStream}.
*/ */
async startScreenShare(includeAudio: boolean = false): Promise<MediaStream> { async startScreenShare(includeAudio = false): Promise<MediaStream> {
const stream = await this.screenShareManager.startScreenShare(includeAudio); const stream = await this.screenShareManager.startScreenShare(includeAudio);
this._isScreenSharing.set(true); this._isScreenSharing.set(true);
this._screenStreamSignal.set(stream); this._screenStreamSignal.set(stream);
return stream; return stream;
@@ -698,9 +727,12 @@ export class WebRTCService implements OnDestroy {
/** Returns true if a peer connection exists and its data channel is open. */ /** Returns true if a peer connection exists and its data channel is open. */
private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean { private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean {
if (!peer) return false; if (!peer)
return false;
const connState = peer.connection?.connectionState; const connState = peer.connection?.connectionState;
const dcState = peer.dataChannel?.readyState; const dcState = peer.dataChannel?.readyState;
return connState === 'connected' && dcState === 'open'; return connState === 'connected' && dcState === 'open';
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, id-length */
/** /**
* Manages local voice media: getUserMedia, mute, deafen, * Manages local voice media: getUserMedia, mute, deafen,
* attaching/detaching audio tracks to peer connections, bitrate tuning, * attaching/detaching audio tracks to peer connections, bitrate tuning,
@@ -22,7 +23,7 @@ import {
VOICE_HEARTBEAT_INTERVAL_MS, VOICE_HEARTBEAT_INTERVAL_MS,
DEFAULT_DISPLAY_NAME, DEFAULT_DISPLAY_NAME,
P2P_TYPE_VOICE_STATE, P2P_TYPE_VOICE_STATE,
LatencyProfile, LatencyProfile
} from './webrtc.constants'; } from './webrtc.constants';
/** /**
@@ -82,7 +83,7 @@ export class MediaManager {
constructor( constructor(
private readonly logger: WebRTCLogger, private readonly logger: WebRTCLogger,
private callbacks: MediaManagerCallbacks, private callbacks: MediaManagerCallbacks
) { ) {
this.noiseReduction = new NoiseReductionManager(logger); this.noiseReduction = new NoiseReductionManager(logger);
} }
@@ -152,21 +153,23 @@ export class MediaManager {
audio: { audio: {
echoCancellation: true, echoCancellation: true,
noiseSuppression: true, noiseSuppression: true,
autoGainControl: true, autoGainControl: true
}, },
video: false, video: false
}; };
this.logger.info('getUserMedia constraints', mediaConstraints); this.logger.info('getUserMedia constraints', mediaConstraints);
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
throw new Error( throw new Error(
'navigator.mediaDevices is not available. ' + 'navigator.mediaDevices is not available. ' +
'This requires a secure context (HTTPS or localhost). ' + 'This requires a secure context (HTTPS or localhost). ' +
'If accessing from an external device, use HTTPS.', 'If accessing from an external device, use HTTPS.'
); );
} }
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints); const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
this.rawMicStream = stream; this.rawMicStream = stream;
// If the user wants noise reduction, pipe through the denoiser // If the user wants noise reduction, pipe through the denoiser
@@ -200,11 +203,13 @@ export class MediaManager {
this.rawMicStream.getTracks().forEach((track) => track.stop()); this.rawMicStream.getTracks().forEach((track) => track.stop());
this.rawMicStream = null; this.rawMicStream = null;
} }
this.localMediaStream = null; this.localMediaStream = null;
// Remove audio senders but keep connections alive // Remove audio senders but keep connections alive
this.callbacks.getActivePeers().forEach((peerData) => { this.callbacks.getActivePeers().forEach((peerData) => {
const senders = peerData.connection.getSenders(); const senders = peerData.connection.getSenders();
senders.forEach((sender) => { senders.forEach((sender) => {
if (sender.track?.kind === TRACK_KIND_AUDIO) { if (sender.track?.kind === TRACK_KIND_AUDIO) {
peerData.connection.removeTrack(sender); peerData.connection.removeTrack(sender);
@@ -250,6 +255,7 @@ export class MediaManager {
if (this.localMediaStream) { if (this.localMediaStream) {
const audioTracks = this.localMediaStream.getAudioTracks(); const audioTracks = this.localMediaStream.getAudioTracks();
const newMutedState = muted !== undefined ? muted : !this.isMicMuted; const newMutedState = muted !== undefined ? muted : !this.isMicMuted;
audioTracks.forEach((track) => { audioTracks.forEach((track) => {
track.enabled = !newMutedState; track.enabled = !newMutedState;
}); });
@@ -284,23 +290,27 @@ export class MediaManager {
'Noise reduction desired =', 'Noise reduction desired =',
shouldEnable, shouldEnable,
'| worklet active =', '| worklet active =',
this.noiseReduction.isEnabled, this.noiseReduction.isEnabled
); );
if (shouldEnable === this.noiseReduction.isEnabled) return; if (shouldEnable === this.noiseReduction.isEnabled)
return;
if (shouldEnable) { if (shouldEnable) {
if (!this.rawMicStream) { if (!this.rawMicStream) {
this.logger.warn( this.logger.warn(
'Cannot enable noise reduction — no mic stream yet (will apply on connect)', 'Cannot enable noise reduction — no mic stream yet (will apply on connect)'
); );
return; return;
} }
this.logger.info('Enabling noise reduction on raw mic stream'); this.logger.info('Enabling noise reduction on raw mic stream');
const cleanStream = await this.noiseReduction.enable(this.rawMicStream); const cleanStream = await this.noiseReduction.enable(this.rawMicStream);
this.localMediaStream = cleanStream; this.localMediaStream = cleanStream;
} else { } else {
this.noiseReduction.disable(); this.noiseReduction.disable();
if (this.rawMicStream) { if (this.rawMicStream) {
this.localMediaStream = this.rawMicStream; this.localMediaStream = this.rawMicStream;
} }
@@ -330,23 +340,29 @@ export class MediaManager {
async setAudioBitrate(kbps: number): Promise<void> { async setAudioBitrate(kbps: number): Promise<void> {
const targetBps = Math.max( const targetBps = Math.max(
AUDIO_BITRATE_MIN_BPS, AUDIO_BITRATE_MIN_BPS,
Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS)), Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS))
); );
this.callbacks.getActivePeers().forEach(async (peerData) => { this.callbacks.getActivePeers().forEach(async (peerData) => {
const sender = const sender =
peerData.audioSender || peerData.audioSender ||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO); peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
if (!sender?.track) return;
if (peerData.connection.signalingState !== 'stable') return; if (!sender?.track)
return;
if (peerData.connection.signalingState !== 'stable')
return;
let params: RTCRtpSendParameters; let params: RTCRtpSendParameters;
try { try {
params = sender.getParameters(); params = sender.getParameters();
} catch (error) { } catch (error) {
this.logger.warn('getParameters failed; skipping bitrate apply', error as any); this.logger.warn('getParameters failed; skipping bitrate apply', error as any);
return; return;
} }
params.encodings = params.encodings || [{}]; params.encodings = params.encodings || [{}];
params.encodings[0].maxBitrate = targetBps; params.encodings[0].maxBitrate = targetBps;
@@ -380,8 +396,11 @@ export class MediaManager {
this.stopVoiceHeartbeat(); this.stopVoiceHeartbeat();
// Persist voice channel context so heartbeats and state snapshots include it // Persist voice channel context so heartbeats and state snapshots include it
if (roomId !== undefined) this.currentVoiceRoomId = roomId; if (roomId !== undefined)
if (serverId !== undefined) this.currentVoiceServerId = serverId; this.currentVoiceRoomId = roomId;
if (serverId !== undefined)
this.currentVoiceServerId = serverId;
this.voicePresenceTimer = setInterval(() => { this.voicePresenceTimer = setInterval(() => {
if (this.isVoiceActive) { if (this.isVoiceActive) {
@@ -410,7 +429,9 @@ export class MediaManager {
*/ */
private bindLocalTracksToAllPeers(): void { private bindLocalTracksToAllPeers(): void {
const peers = this.callbacks.getActivePeers(); const peers = this.callbacks.getActivePeers();
if (!this.localMediaStream) return;
if (!this.localMediaStream)
return;
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null; const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null; const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
@@ -420,17 +441,20 @@ export class MediaManager {
let audioSender = let audioSender =
peerData.audioSender || peerData.audioSender ||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO); peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) { if (!audioSender) {
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
direction: TRANSCEIVER_SEND_RECV, direction: TRANSCEIVER_SEND_RECV
}).sender; }).sender;
} }
peerData.audioSender = audioSender; peerData.audioSender = audioSender;
// Restore direction after removeTrack (which sets it to recvonly) // Restore direction after removeTrack (which sets it to recvonly)
const audioTransceiver = peerData.connection const audioTransceiver = peerData.connection
.getTransceivers() .getTransceivers()
.find((t) => t.sender === audioSender); .find((t) => t.sender === audioSender);
if ( if (
audioTransceiver && audioTransceiver &&
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY || (audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
@@ -449,16 +473,19 @@ export class MediaManager {
let videoSender = let videoSender =
peerData.videoSender || peerData.videoSender ||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO); peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO);
if (!videoSender) { if (!videoSender) {
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
direction: TRANSCEIVER_SEND_RECV, direction: TRANSCEIVER_SEND_RECV
}).sender; }).sender;
} }
peerData.videoSender = videoSender; peerData.videoSender = videoSender;
const videoTransceiver = peerData.connection const videoTransceiver = peerData.connection
.getTransceivers() .getTransceivers()
.find((t) => t.sender === videoSender); .find((t) => t.sender === videoSender);
if ( if (
videoTransceiver && videoTransceiver &&
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY || (videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
@@ -481,6 +508,7 @@ export class MediaManager {
private broadcastVoicePresence(): void { private broadcastVoicePresence(): void {
const oderId = this.callbacks.getIdentifyOderId(); const oderId = this.callbacks.getIdentifyOderId();
const displayName = this.callbacks.getIdentifyDisplayName(); const displayName = this.callbacks.getIdentifyDisplayName();
this.callbacks.broadcastMessage({ this.callbacks.broadcastMessage({
type: P2P_TYPE_VOICE_STATE, type: P2P_TYPE_VOICE_STATE,
oderId, oderId,
@@ -490,8 +518,8 @@ export class MediaManager {
isMuted: this.isMicMuted, isMuted: this.isMicMuted,
isDeafened: this.isSelfDeafened, isDeafened: this.isSelfDeafened,
roomId: this.currentVoiceRoomId, roomId: this.currentVoiceRoomId,
serverId: this.currentVoiceServerId, serverId: this.currentVoiceServerId
}, }
}); });
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/** /**
* Manages RNNoise-based noise reduction for microphone audio. * Manages RNNoise-based noise reduction for microphone audio.
* *
@@ -17,10 +18,8 @@ import { WebRTCLogger } from './webrtc-logger';
/** Name used to register / instantiate the AudioWorklet processor. */ /** Name used to register / instantiate the AudioWorklet processor. */
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet'; const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
/** RNNoise is trained on 48 kHz audio — the AudioContext must match. */ /** RNNoise is trained on 48 kHz audio — the AudioContext must match. */
const RNNOISE_SAMPLE_RATE = 48_000; const RNNOISE_SAMPLE_RATE = 48_000;
/** /**
* Relative path (from the served application root) to the **bundled** * Relative path (from the served application root) to the **bundled**
* worklet script placed in `public/` and served as a static asset. * worklet script placed in `public/` and served as a static asset.
@@ -92,7 +91,9 @@ export class NoiseReductionManager {
* used again (the caller is responsible for re-binding tracks). * used again (the caller is responsible for re-binding tracks).
*/ */
disable(): void { disable(): void {
if (!this._isEnabled) return; if (!this._isEnabled)
return;
this.teardownGraph(); this.teardownGraph();
this._isEnabled = false; this._isEnabled = false;
this.logger.info('Noise reduction disabled'); this.logger.info('Noise reduction disabled');
@@ -108,7 +109,8 @@ export class NoiseReductionManager {
* @returns The denoised stream, or the raw stream on failure. * @returns The denoised stream, or the raw stream on failure.
*/ */
async replaceInputStream(rawStream: MediaStream): Promise<MediaStream> { async replaceInputStream(rawStream: MediaStream): Promise<MediaStream> {
if (!this._isEnabled) return rawStream; if (!this._isEnabled)
return rawStream;
try { try {
// Disconnect old source but keep the rest of the graph alive // Disconnect old source but keep the rest of the graph alive
@@ -176,11 +178,13 @@ export class NoiseReductionManager {
} catch { } catch {
/* already disconnected */ /* already disconnected */
} }
try { try {
this.workletNode?.disconnect(); this.workletNode?.disconnect();
} catch { } catch {
/* already disconnected */ /* already disconnected */
} }
try { try {
this.destinationNode?.disconnect(); this.destinationNode?.disconnect();
} catch { } catch {
@@ -197,6 +201,7 @@ export class NoiseReductionManager {
/* best-effort */ /* best-effort */
}); });
} }
this.audioContext = null; this.audioContext = null;
this.workletLoaded = false; this.workletLoaded = false;
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length */
/** /**
* Creates and manages RTCPeerConnections, data channels, * Creates and manages RTCPeerConnections, data channels,
* offer/answer negotiation, ICE candidates, and P2P reconnection. * offer/answer negotiation, ICE candidates, and P2P reconnection.
@@ -9,7 +10,7 @@ import {
PeerData, PeerData,
DisconnectedPeerEntry, DisconnectedPeerEntry,
VoiceStateSnapshot, VoiceStateSnapshot,
IdentifyCredentials, IdentifyCredentials
} from './webrtc.types'; } from './webrtc.types';
import { import {
ICE_SERVERS, ICE_SERVERS,
@@ -37,7 +38,7 @@ import {
SIGNALING_TYPE_OFFER, SIGNALING_TYPE_OFFER,
SIGNALING_TYPE_ANSWER, SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_ICE_CANDIDATE, SIGNALING_TYPE_ICE_CANDIDATE,
DEFAULT_DISPLAY_NAME, DEFAULT_DISPLAY_NAME
} from './webrtc.constants'; } from './webrtc.constants';
/** /**
@@ -97,7 +98,7 @@ export class PeerConnectionManager {
constructor( constructor(
private readonly logger: WebRTCLogger, private readonly logger: WebRTCLogger,
private callbacks: PeerConnectionCallbacks, private callbacks: PeerConnectionCallbacks
) {} ) {}
/** /**
@@ -125,6 +126,7 @@ export class PeerConnectionManager {
this.logger.info('Creating peer connection', { remotePeerId, isInitiator }); this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS }); const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
let dataChannel: RTCDataChannel | null = null; let dataChannel: RTCDataChannel | null = null;
// ICE candidates → signaling // ICE candidates → signaling
@@ -132,12 +134,12 @@ export class PeerConnectionManager {
if (event.candidate) { if (event.candidate) {
this.logger.info('ICE candidate gathered', { this.logger.info('ICE candidate gathered', {
remotePeerId, remotePeerId,
candidateType: (event.candidate as any)?.type, candidateType: (event.candidate as any)?.type
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ICE_CANDIDATE, type: SIGNALING_TYPE_ICE_CANDIDATE,
targetUserId: remotePeerId, targetUserId: remotePeerId,
payload: { candidate: event.candidate }, payload: { candidate: event.candidate }
}); });
} }
}; };
@@ -146,7 +148,7 @@ export class PeerConnectionManager {
connection.onconnectionstatechange = () => { connection.onconnectionstatechange = () => {
this.logger.info('connectionstatechange', { this.logger.info('connectionstatechange', {
remotePeerId, remotePeerId,
state: connection.connectionState, state: connection.connectionState
}); });
switch (connection.connectionState) { switch (connection.connectionState) {
@@ -175,12 +177,14 @@ export class PeerConnectionManager {
connection.oniceconnectionstatechange = () => { connection.oniceconnectionstatechange = () => {
this.logger.info('iceconnectionstatechange', { this.logger.info('iceconnectionstatechange', {
remotePeerId, remotePeerId,
state: connection.iceConnectionState, state: connection.iceConnectionState
}); });
}; };
connection.onsignalingstatechange = () => { connection.onsignalingstatechange = () => {
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState }); this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState });
}; };
connection.onnegotiationneeded = () => { connection.onnegotiationneeded = () => {
this.logger.info('negotiationneeded', { remotePeerId }); this.logger.info('negotiationneeded', { remotePeerId });
}; };
@@ -199,9 +203,11 @@ export class PeerConnectionManager {
this.logger.info('Received data channel', { remotePeerId }); this.logger.info('Received data channel', { remotePeerId });
dataChannel = event.channel; dataChannel = event.channel;
const existing = this.activePeerConnections.get(remotePeerId); const existing = this.activePeerConnections.get(remotePeerId);
if (existing) { if (existing) {
existing.dataChannel = dataChannel; existing.dataChannel = dataChannel;
} }
this.setupDataChannel(dataChannel, remotePeerId); this.setupDataChannel(dataChannel, remotePeerId);
}; };
} }
@@ -212,17 +218,18 @@ export class PeerConnectionManager {
isInitiator, isInitiator,
pendingIceCandidates: [], pendingIceCandidates: [],
audioSender: undefined, audioSender: undefined,
videoSender: undefined, videoSender: undefined
}; };
// Pre-create transceivers only for the initiator (offerer). // Pre-create transceivers only for the initiator (offerer).
if (isInitiator) { if (isInitiator) {
const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, { const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, {
direction: TRANSCEIVER_SEND_RECV, direction: TRANSCEIVER_SEND_RECV
}); });
const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, { const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, {
direction: TRANSCEIVER_RECV_ONLY, direction: TRANSCEIVER_RECV_ONLY
}); });
peerData.audioSender = audioTransceiver.sender; peerData.audioSender = audioTransceiver.sender;
peerData.videoSender = videoTransceiver.sender; peerData.videoSender = videoTransceiver.sender;
} }
@@ -231,6 +238,7 @@ export class PeerConnectionManager {
// Attach local stream to initiator // Attach local stream to initiator
const localStream = this.callbacks.getLocalMediaStream(); const localStream = this.callbacks.getLocalMediaStream();
if (localStream && isInitiator) { if (localStream && isInitiator) {
this.logger.logStream(`localStream->${remotePeerId}`, localStream); this.logger.logStream(`localStream->${remotePeerId}`, localStream);
localStream.getTracks().forEach((track) => { localStream.getTracks().forEach((track) => {
@@ -239,19 +247,23 @@ export class PeerConnectionManager {
.replaceTrack(track) .replaceTrack(track)
.then(() => this.logger.info('audio replaceTrack (init) ok', { remotePeerId })) .then(() => this.logger.info('audio replaceTrack (init) ok', { remotePeerId }))
.catch((e) => .catch((e) =>
this.logger.error('audio replaceTrack failed at createPeerConnection', e), this.logger.error('audio replaceTrack failed at createPeerConnection', e)
); );
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) { } else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
peerData.videoSender peerData.videoSender
.replaceTrack(track) .replaceTrack(track)
.then(() => this.logger.info('video replaceTrack (init) ok', { remotePeerId })) .then(() => this.logger.info('video replaceTrack (init) ok', { remotePeerId }))
.catch((e) => .catch((e) =>
this.logger.error('video replaceTrack failed at createPeerConnection', e), this.logger.error('video replaceTrack failed at createPeerConnection', e)
); );
} else { } else {
const sender = connection.addTrack(track, localStream); const sender = connection.addTrack(track, localStream);
if (track.kind === TRACK_KIND_AUDIO) peerData.audioSender = sender;
if (track.kind === TRACK_KIND_VIDEO) peerData.videoSender = sender; if (track.kind === TRACK_KIND_AUDIO)
peerData.audioSender = sender;
if (track.kind === TRACK_KIND_VIDEO)
peerData.videoSender = sender;
} }
}); });
} }
@@ -277,20 +289,23 @@ export class PeerConnectionManager {
private async doCreateAndSendOffer(remotePeerId: string): Promise<void> { private async doCreateAndSendOffer(remotePeerId: string): Promise<void> {
const peerData = this.activePeerConnections.get(remotePeerId); const peerData = this.activePeerConnections.get(remotePeerId);
if (!peerData) return;
if (!peerData)
return;
try { try {
const offer = await peerData.connection.createOffer(); const offer = await peerData.connection.createOffer();
await peerData.connection.setLocalDescription(offer); await peerData.connection.setLocalDescription(offer);
this.logger.info('Sending offer', { this.logger.info('Sending offer', {
remotePeerId, remotePeerId,
type: offer.type, type: offer.type,
sdpLength: offer.sdp?.length, sdpLength: offer.sdp?.length
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER, type: SIGNALING_TYPE_OFFER,
targetUserId: remotePeerId, targetUserId: remotePeerId,
payload: { sdp: offer }, payload: { sdp: offer }
}); });
} catch (error) { } catch (error) {
this.logger.error('Failed to create offer', error); this.logger.error('Failed to create offer', error);
@@ -311,6 +326,7 @@ export class PeerConnectionManager {
private enqueueNegotiation(peerId: string, task: () => Promise<void>): void { private enqueueNegotiation(peerId: string, task: () => Promise<void>): void {
const prev = this.peerNegotiationQueue.get(peerId) ?? Promise.resolve(); const prev = this.peerNegotiationQueue.get(peerId) ?? Promise.resolve();
const next = prev.then(task, task); // always chain, even after rejection const next = prev.then(task, task); // always chain, even after rejection
this.peerNegotiationQueue.set(peerId, next); this.peerNegotiationQueue.set(peerId, next);
} }
@@ -336,6 +352,7 @@ export class PeerConnectionManager {
this.logger.info('Handling offer', { fromUserId }); this.logger.info('Handling offer', { fromUserId });
let peerData = this.activePeerConnections.get(fromUserId); let peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) { if (!peerData) {
peerData = this.createPeerConnection(fromUserId, false); peerData = this.createPeerConnection(fromUserId, false);
} }
@@ -359,7 +376,7 @@ export class PeerConnectionManager {
this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId }); this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId });
await peerData.connection.setLocalDescription({ await peerData.connection.setLocalDescription({
type: 'rollback', type: 'rollback'
} as RTCSessionDescriptionInit); } as RTCSessionDescriptionInit);
} }
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
@@ -371,12 +388,15 @@ export class PeerConnectionManager {
// Without this, the answerer's SDP answer defaults to recvonly for audio, // Without this, the answerer's SDP answer defaults to recvonly for audio,
// making the connection one-way (only the offerer's audio is heard). // making the connection one-way (only the offerer's audio is heard).
const transceivers = peerData.connection.getTransceivers(); const transceivers = peerData.connection.getTransceivers();
for (const transceiver of transceivers) { for (const transceiver of transceivers) {
const receiverKind = transceiver.receiver.track?.kind; const receiverKind = transceiver.receiver.track?.kind;
if (receiverKind === TRACK_KIND_AUDIO) { if (receiverKind === TRACK_KIND_AUDIO) {
if (!peerData.audioSender) { if (!peerData.audioSender) {
peerData.audioSender = transceiver.sender; peerData.audioSender = transceiver.sender;
} }
// Promote to sendrecv so the SDP answer includes a send direction, // Promote to sendrecv so the SDP answer includes a send direction,
// enabling bidirectional audio regardless of who initiated the connection. // enabling bidirectional audio regardless of who initiated the connection.
transceiver.direction = TRANSCEIVER_SEND_RECV; transceiver.direction = TRANSCEIVER_SEND_RECV;
@@ -387,8 +407,10 @@ export class PeerConnectionManager {
// Attach local tracks (answerer side) // Attach local tracks (answerer side)
const localStream = this.callbacks.getLocalMediaStream(); const localStream = this.callbacks.getLocalMediaStream();
if (localStream) { if (localStream) {
this.logger.logStream(`localStream->${fromUserId} (answerer)`, localStream); this.logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
for (const track of localStream.getTracks()) { for (const track of localStream.getTracks()) {
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) { if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
await peerData.audioSender.replaceTrack(track); await peerData.audioSender.replaceTrack(track);
@@ -404,20 +426,22 @@ export class PeerConnectionManager {
for (const candidate of peerData.pendingIceCandidates) { for (const candidate of peerData.pendingIceCandidates) {
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
} }
peerData.pendingIceCandidates = []; peerData.pendingIceCandidates = [];
const answer = await peerData.connection.createAnswer(); const answer = await peerData.connection.createAnswer();
await peerData.connection.setLocalDescription(answer); await peerData.connection.setLocalDescription(answer);
this.logger.info('Sending answer', { this.logger.info('Sending answer', {
to: fromUserId, to: fromUserId,
type: answer.type, type: answer.type,
sdpLength: answer.sdp?.length, sdpLength: answer.sdp?.length
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ANSWER, type: SIGNALING_TYPE_ANSWER,
targetUserId: fromUserId, targetUserId: fromUserId,
payload: { sdp: answer }, payload: { sdp: answer }
}); });
} catch (error) { } catch (error) {
this.logger.error('Failed to handle offer', error); this.logger.error('Failed to handle offer', error);
@@ -442,6 +466,7 @@ export class PeerConnectionManager {
private async doHandleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> { private async doHandleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
this.logger.info('Handling answer', { fromUserId }); this.logger.info('Handling answer', { fromUserId });
const peerData = this.activePeerConnections.get(fromUserId); const peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) { if (!peerData) {
this.logger.error('No peer for answer', new Error('Missing peer'), { fromUserId }); this.logger.error('No peer for answer', new Error('Missing peer'), { fromUserId });
return; return;
@@ -450,13 +475,15 @@ export class PeerConnectionManager {
try { try {
if (peerData.connection.signalingState === 'have-local-offer') { if (peerData.connection.signalingState === 'have-local-offer') {
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
for (const candidate of peerData.pendingIceCandidates) { for (const candidate of peerData.pendingIceCandidates) {
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
} }
peerData.pendingIceCandidates = []; peerData.pendingIceCandidates = [];
} else { } else {
this.logger.warn('Ignoring answer wrong signaling state', { this.logger.warn('Ignoring answer wrong signaling state', {
state: peerData.connection.signalingState, state: peerData.connection.signalingState
}); });
} }
} catch (error) { } catch (error) {
@@ -481,9 +508,10 @@ export class PeerConnectionManager {
private async doHandleIceCandidate( private async doHandleIceCandidate(
fromUserId: string, fromUserId: string,
candidate: RTCIceCandidateInit, candidate: RTCIceCandidateInit
): Promise<void> { ): Promise<void> {
let peerData = this.activePeerConnections.get(fromUserId); let peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) { if (!peerData) {
this.logger.info('Creating peer for early ICE', { fromUserId }); this.logger.info('Creating peer for early ICE', { fromUserId });
peerData = this.createPeerConnection(fromUserId, false); peerData = this.createPeerConnection(fromUserId, false);
@@ -518,20 +546,23 @@ export class PeerConnectionManager {
private async doRenegotiate(peerId: string): Promise<void> { private async doRenegotiate(peerId: string): Promise<void> {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (!peerData) return;
if (!peerData)
return;
try { try {
const offer = await peerData.connection.createOffer(); const offer = await peerData.connection.createOffer();
await peerData.connection.setLocalDescription(offer); await peerData.connection.setLocalDescription(offer);
this.logger.info('Renegotiate offer', { this.logger.info('Renegotiate offer', {
peerId, peerId,
type: offer.type, type: offer.type,
sdpLength: offer.sdp?.length, sdpLength: offer.sdp?.length
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER, type: SIGNALING_TYPE_OFFER,
targetUserId: peerId, targetUserId: peerId,
payload: { sdp: offer }, payload: { sdp: offer }
}); });
} catch (error) { } catch (error) {
this.logger.error('Failed to renegotiate', error); this.logger.error('Failed to renegotiate', error);
@@ -551,11 +582,13 @@ export class PeerConnectionManager {
channel.onopen = () => { channel.onopen = () => {
this.logger.info('Data channel open', { remotePeerId }); this.logger.info('Data channel open', { remotePeerId });
this.sendCurrentStatesToChannel(channel, remotePeerId); this.sendCurrentStatesToChannel(channel, remotePeerId);
try { try {
channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST }));
} catch { } catch {
/* ignore */ /* ignore */
} }
this.startPingInterval(remotePeerId); this.startPingInterval(remotePeerId);
}; };
@@ -570,6 +603,7 @@ export class PeerConnectionManager {
channel.onmessage = (event) => { channel.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
this.handlePeerMessage(remotePeerId, message); this.handlePeerMessage(remotePeerId, message);
} catch (error) { } catch (error) {
this.logger.error('Failed to parse peer message', error); this.logger.error('Failed to parse peer message', error);
@@ -600,24 +634,30 @@ export class PeerConnectionManager {
this.sendToPeer(peerId, { type: P2P_TYPE_PONG, ts: message.ts } as any); this.sendToPeer(peerId, { type: P2P_TYPE_PONG, ts: message.ts } as any);
return; return;
} }
if (message.type === P2P_TYPE_PONG) { if (message.type === P2P_TYPE_PONG) {
const sent = this.pendingPings.get(peerId); const sent = this.pendingPings.get(peerId);
if (sent && typeof message.ts === 'number' && message.ts === sent) { if (sent && typeof message.ts === 'number' && message.ts === sent) {
const latencyMs = Math.round(performance.now() - sent); const latencyMs = Math.round(performance.now() - sent);
this.peerLatencies.set(peerId, latencyMs); this.peerLatencies.set(peerId, latencyMs);
this.peerLatencyChanged$.next({ peerId, latencyMs }); this.peerLatencyChanged$.next({ peerId, latencyMs });
} }
this.pendingPings.delete(peerId); this.pendingPings.delete(peerId);
return; return;
} }
const enriched = { ...message, fromPeerId: peerId }; const enriched = { ...message, fromPeerId: peerId };
this.messageReceived$.next(enriched); this.messageReceived$.next(enriched);
} }
/** Broadcast a ChatEvent to every peer with an open data channel. */ /** Broadcast a ChatEvent to every peer with an open data channel. */
broadcastMessage(event: ChatEvent): void { broadcastMessage(event: ChatEvent): void {
const data = JSON.stringify(event); const data = JSON.stringify(event);
this.activePeerConnections.forEach((peerData, peerId) => { this.activePeerConnections.forEach((peerData, peerId) => {
try { try {
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
@@ -640,10 +680,12 @@ export class PeerConnectionManager {
*/ */
sendToPeer(peerId: string, event: ChatEvent): void { sendToPeer(peerId: string, event: ChatEvent): void {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) { if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
this.logger.warn('Peer not connected cannot send', { peerId }); this.logger.warn('Peer not connected cannot send', { peerId });
return; return;
} }
try { try {
peerData.dataChannel.send(JSON.stringify(event)); peerData.dataChannel.send(JSON.stringify(event));
} catch (error) { } catch (error) {
@@ -662,6 +704,7 @@ export class PeerConnectionManager {
*/ */
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> { async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) { if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
this.logger.warn('Peer not connected cannot send buffered', { peerId }); this.logger.warn('Peer not connected cannot send buffered', { peerId });
return; return;
@@ -682,6 +725,7 @@ export class PeerConnectionManager {
resolve(); resolve();
} }
}; };
channel.addEventListener('bufferedamountlow', handler as any, { once: true } as any); channel.addEventListener('bufferedamountlow', handler as any, { once: true } as any);
}); });
} }
@@ -709,7 +753,7 @@ export class PeerConnectionManager {
type: P2P_TYPE_SCREEN_STATE, type: P2P_TYPE_SCREEN_STATE,
oderId, oderId,
displayName, displayName,
isScreenSharing: this.callbacks.isScreenSharingActive(), isScreenSharing: this.callbacks.isScreenSharingActive()
} as any); } as any);
} }
@@ -717,10 +761,11 @@ export class PeerConnectionManager {
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) { if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
this.logger.warn('Cannot send states channel not open', { this.logger.warn('Cannot send states channel not open', {
remotePeerId, remotePeerId,
state: channel.readyState, state: channel.readyState
}); });
return; return;
} }
const credentials = this.callbacks.getIdentifyCredentials(); const credentials = this.callbacks.getIdentifyCredentials();
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId(); const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME; const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
@@ -733,8 +778,8 @@ export class PeerConnectionManager {
type: P2P_TYPE_SCREEN_STATE, type: P2P_TYPE_SCREEN_STATE,
oderId, oderId,
displayName, displayName,
isScreenSharing: this.callbacks.isScreenSharingActive(), isScreenSharing: this.callbacks.isScreenSharingActive()
}), })
); );
this.logger.info('Sent initial states to channel', { remotePeerId, voiceState }); this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
} catch (e) { } catch (e) {
@@ -754,7 +799,7 @@ export class PeerConnectionManager {
type: P2P_TYPE_SCREEN_STATE, type: P2P_TYPE_SCREEN_STATE,
oderId, oderId,
displayName, displayName,
isScreenSharing: this.callbacks.isScreenSharingActive(), isScreenSharing: this.callbacks.isScreenSharingActive()
} as any); } as any);
} }
@@ -762,13 +807,14 @@ export class PeerConnectionManager {
const track = event.track; const track = event.track;
const settings = const settings =
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings); typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
this.logger.info('Remote track', { this.logger.info('Remote track', {
remotePeerId, remotePeerId,
kind: track.kind, kind: track.kind,
id: track.id, id: track.id,
enabled: track.enabled, enabled: track.enabled,
readyState: track.readyState, readyState: track.readyState,
settings, settings
}); });
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`); this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
@@ -777,16 +823,17 @@ export class PeerConnectionManager {
this.logger.info('Skipping inactive video track', { this.logger.info('Skipping inactive video track', {
remotePeerId, remotePeerId,
enabled: track.enabled, enabled: track.enabled,
readyState: track.readyState, readyState: track.readyState
}); });
return; return;
} }
// Merge into composite stream per peer // Merge into composite stream per peer
let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream(); const compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream();
const trackAlreadyAdded = compositeStream const trackAlreadyAdded = compositeStream
.getTracks() .getTracks()
.some((existingTrack) => existingTrack.id === track.id); .some((existingTrack) => existingTrack.id === track.id);
if (!trackAlreadyAdded) { if (!trackAlreadyAdded) {
try { try {
compositeStream.addTrack(track); compositeStream.addTrack(track);
@@ -794,6 +841,7 @@ export class PeerConnectionManager {
this.logger.warn('Failed to add track to composite stream', e as any); this.logger.warn('Failed to add track to composite stream', e as any);
} }
} }
this.remotePeerStreams.set(remotePeerId, compositeStream); this.remotePeerStreams.set(remotePeerId, compositeStream);
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream }); this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
} }
@@ -805,8 +853,11 @@ export class PeerConnectionManager {
*/ */
removePeer(peerId: string): void { removePeer(peerId: string): void {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (peerData) { if (peerData) {
if (peerData.dataChannel) peerData.dataChannel.close(); if (peerData.dataChannel)
peerData.dataChannel.close();
peerData.connection.close(); peerData.connection.close();
this.activePeerConnections.delete(peerId); this.activePeerConnections.delete(peerId);
this.peerNegotiationQueue.delete(peerId); this.peerNegotiationQueue.delete(peerId);
@@ -823,7 +874,9 @@ export class PeerConnectionManager {
this.clearAllPeerReconnectTimers(); this.clearAllPeerReconnectTimers();
this.clearAllPingTimers(); this.clearAllPingTimers();
this.activePeerConnections.forEach((peerData) => { this.activePeerConnections.forEach((peerData) => {
if (peerData.dataChannel) peerData.dataChannel.close(); if (peerData.dataChannel)
peerData.dataChannel.close();
peerData.connection.close(); peerData.connection.close();
}); });
this.activePeerConnections.clear(); this.activePeerConnections.clear();
@@ -836,12 +889,13 @@ export class PeerConnectionManager {
private trackDisconnectedPeer(peerId: string): void { private trackDisconnectedPeer(peerId: string): void {
this.disconnectedPeerTracker.set(peerId, { this.disconnectedPeerTracker.set(peerId, {
lastSeenTimestamp: Date.now(), lastSeenTimestamp: Date.now(),
reconnectAttempts: 0, reconnectAttempts: 0
}); });
} }
private clearPeerReconnectTimer(peerId: string): void { private clearPeerReconnectTimer(peerId: string): void {
const timer = this.peerReconnectTimers.get(peerId); const timer = this.peerReconnectTimers.get(peerId);
if (timer) { if (timer) {
clearInterval(timer); clearInterval(timer);
this.peerReconnectTimers.delete(peerId); this.peerReconnectTimers.delete(peerId);
@@ -856,11 +910,14 @@ export class PeerConnectionManager {
} }
private schedulePeerReconnect(peerId: string): void { private schedulePeerReconnect(peerId: string): void {
if (this.peerReconnectTimers.has(peerId)) return; if (this.peerReconnectTimers.has(peerId))
return;
this.logger.info('Scheduling P2P reconnect', { peerId }); this.logger.info('Scheduling P2P reconnect', { peerId });
const timer = setInterval(() => { const timer = setInterval(() => {
const info = this.disconnectedPeerTracker.get(peerId); const info = this.disconnectedPeerTracker.get(peerId);
if (!info) { if (!info) {
this.clearPeerReconnectTimer(peerId); this.clearPeerReconnectTimer(peerId);
return; return;
@@ -889,20 +946,24 @@ export class PeerConnectionManager {
private attemptPeerReconnect(peerId: string): void { private attemptPeerReconnect(peerId: string): void {
const existing = this.activePeerConnections.get(peerId); const existing = this.activePeerConnections.get(peerId);
if (existing) { if (existing) {
try { try {
existing.connection.close(); existing.connection.close();
} catch { } catch {
/* ignore */ /* ignore */
} }
this.activePeerConnections.delete(peerId); this.activePeerConnections.delete(peerId);
} }
this.createPeerConnection(peerId, true); this.createPeerConnection(peerId, true);
this.createAndSendOffer(peerId); this.createAndSendOffer(peerId);
} }
private requestVoiceStateFromPeer(peerId: string): void { private requestVoiceStateFromPeer(peerId: string): void {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
try { try {
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST })); peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST }));
@@ -933,7 +994,7 @@ export class PeerConnectionManager {
*/ */
private removeFromConnectedPeers(peerId: string): void { private removeFromConnectedPeers(peerId: string): void {
this.connectedPeersList = this.connectedPeersList.filter( this.connectedPeersList = this.connectedPeersList.filter(
(connectedId) => connectedId !== peerId, (connectedId) => connectedId !== peerId
); );
this.connectedPeersChanged$.next(this.connectedPeersList); this.connectedPeersChanged$.next(this.connectedPeersList);
} }
@@ -954,12 +1015,14 @@ export class PeerConnectionManager {
// Send an immediate ping // Send an immediate ping
this.sendPing(peerId); this.sendPing(peerId);
const timer = setInterval(() => this.sendPing(peerId), PEER_PING_INTERVAL_MS); const timer = setInterval(() => this.sendPing(peerId), PEER_PING_INTERVAL_MS);
this.peerPingTimers.set(peerId, timer); this.peerPingTimers.set(peerId, timer);
} }
/** Stop the periodic ping for a specific peer. */ /** Stop the periodic ping for a specific peer. */
private stopPingInterval(peerId: string): void { private stopPingInterval(peerId: string): void {
const timer = this.peerPingTimers.get(peerId); const timer = this.peerPingTimers.get(peerId);
if (timer) { if (timer) {
clearInterval(timer); clearInterval(timer);
this.peerPingTimers.delete(peerId); this.peerPingTimers.delete(peerId);
@@ -975,10 +1038,14 @@ export class PeerConnectionManager {
/** Send a single ping to a peer. */ /** Send a single ping to a peer. */
private sendPing(peerId: string): void { private sendPing(peerId: string): void {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN)
return; return;
const ts = performance.now(); const ts = performance.now();
this.pendingPings.set(peerId, ts); this.pendingPings.set(peerId, ts);
try { try {
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts })); peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts }));
} catch { } catch {

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-length, id-denylist, max-statements-per-line, max-len */
/** /**
* Manages screen sharing: getDisplayMedia / Electron desktop capturer, * Manages screen sharing: getDisplayMedia / Electron desktop capturer,
* mixed audio (screen + mic), and attaching screen tracks to peers. * mixed audio (screen + mic), and attaching screen tracks to peers.
@@ -12,7 +13,7 @@ import {
SCREEN_SHARE_IDEAL_WIDTH, SCREEN_SHARE_IDEAL_WIDTH,
SCREEN_SHARE_IDEAL_HEIGHT, SCREEN_SHARE_IDEAL_HEIGHT,
SCREEN_SHARE_IDEAL_FRAME_RATE, SCREEN_SHARE_IDEAL_FRAME_RATE,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME, ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
} from './webrtc.constants'; } from './webrtc.constants';
/** /**
@@ -40,7 +41,7 @@ export class ScreenShareManager {
constructor( constructor(
private readonly logger: WebRTCLogger, private readonly logger: WebRTCLogger,
private callbacks: ScreenShareCallbacks, private callbacks: ScreenShareCallbacks
) {} ) {}
/** /**
@@ -69,7 +70,7 @@ export class ScreenShareManager {
* @returns The captured screen {@link MediaStream}. * @returns The captured screen {@link MediaStream}.
* @throws If both Electron and browser screen capture fail. * @throws If both Electron and browser screen capture fail.
*/ */
async startScreenShare(includeSystemAudio: boolean = false): Promise<MediaStream> { async startScreenShare(includeSystemAudio = false): Promise<MediaStream> {
try { try {
this.logger.info('startScreenShare invoked', { includeSystemAudio }); this.logger.info('startScreenShare invoked', { includeSystemAudio });
@@ -78,19 +79,22 @@ export class ScreenShareManager {
try { try {
const sources = await (window as any).electronAPI.getSources(); const sources = await (window as any).electronAPI.getSources();
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0]; const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
const electronConstraints: any = { const electronConstraints: any = {
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }, video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }
}; };
if (includeSystemAudio) { if (includeSystemAudio) {
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }; electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } };
} else { } else {
electronConstraints.audio = false; electronConstraints.audio = false;
} }
this.logger.info('desktopCapturer constraints', electronConstraints); this.logger.info('desktopCapturer constraints', electronConstraints);
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).'); throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
} }
this.activeScreenStream = await navigator.mediaDevices.getUserMedia(electronConstraints); this.activeScreenStream = await navigator.mediaDevices.getUserMedia(electronConstraints);
} catch (e) { } catch (e) {
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any); this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any);
@@ -103,14 +107,17 @@ export class ScreenShareManager {
video: { video: {
width: { ideal: SCREEN_SHARE_IDEAL_WIDTH }, width: { ideal: SCREEN_SHARE_IDEAL_WIDTH },
height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT }, height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT },
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }, frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }
}, },
audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false, audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false
} as any; } as any;
this.logger.info('getDisplayMedia constraints', displayConstraints); this.logger.info('getDisplayMedia constraints', displayConstraints);
if (!navigator.mediaDevices) { if (!navigator.mediaDevices) {
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).'); throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
} }
this.activeScreenStream = await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints); this.activeScreenStream = await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints);
} }
@@ -126,6 +133,7 @@ export class ScreenShareManager {
// Auto-stop when user ends share via browser UI // Auto-stop when user ends share via browser UI
const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0]; const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0];
if (screenVideoTrack) { if (screenVideoTrack) {
screenVideoTrack.onended = () => { screenVideoTrack.onended = () => {
this.logger.warn('Screen video track ended'); this.logger.warn('Screen video track ended');
@@ -157,6 +165,7 @@ export class ScreenShareManager {
// Clean up mixed audio // Clean up mixed audio
if (this.combinedAudioStream) { if (this.combinedAudioStream) {
try { this.combinedAudioStream.getTracks().forEach(track => track.stop()); } catch { /* ignore */ } try { this.combinedAudioStream.getTracks().forEach(track => track.stop()); } catch { /* ignore */ }
this.combinedAudioStream = null; this.combinedAudioStream = null;
} }
@@ -164,26 +173,34 @@ export class ScreenShareManager {
this.callbacks.getActivePeers().forEach((peerData, peerId) => { this.callbacks.getActivePeers().forEach((peerData, peerId) => {
const transceivers = peerData.connection.getTransceivers(); const transceivers = peerData.connection.getTransceivers();
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender); const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
if (videoTransceiver) { if (videoTransceiver) {
videoTransceiver.sender.replaceTrack(null).catch(() => {}); videoTransceiver.sender.replaceTrack(null).catch(() => {});
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) { if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY; videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
} }
} }
peerData.screenVideoSender = undefined; peerData.screenVideoSender = undefined;
peerData.screenAudioSender = undefined; peerData.screenAudioSender = undefined;
// Restore mic track // Restore mic track
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
if (micTrack) { if (micTrack) {
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) { if (!audioSender) {
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
audioSender = transceiver.sender; audioSender = transceiver.sender;
} }
peerData.audioSender = audioSender; peerData.audioSender = audioSender;
audioSender.replaceTrack(micTrack).catch((error) => this.logger.error('Restore mic replaceTrack failed', error)); audioSender.replaceTrack(micTrack).catch((error) => this.logger.error('Restore mic replaceTrack failed', error));
} }
this.callbacks.renegotiate(peerId); this.callbacks.renegotiate(peerId);
}); });
} }
@@ -205,15 +222,18 @@ export class ScreenShareManager {
if (!this.audioMixingContext && (window as any).AudioContext) { if (!this.audioMixingContext && (window as any).AudioContext) {
this.audioMixingContext = new (window as any).AudioContext(); this.audioMixingContext = new (window as any).AudioContext();
} }
if (!this.audioMixingContext) throw new Error('AudioContext not available');
if (!this.audioMixingContext)
throw new Error('AudioContext not available');
const destination = this.audioMixingContext.createMediaStreamDestination(); const destination = this.audioMixingContext.createMediaStreamDestination();
const screenAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([screenAudioTrack])); const screenAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([screenAudioTrack]));
screenAudioSource.connect(destination); screenAudioSource.connect(destination);
if (micAudioTrack) { if (micAudioTrack) {
const micAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([micAudioTrack])); const micAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([micAudioTrack]));
micAudioSource.connect(destination); micAudioSource.connect(destination);
this.logger.info('Mixed mic + screen audio together'); this.logger.info('Mixed mic + screen audio together');
} }
@@ -238,25 +258,33 @@ export class ScreenShareManager {
*/ */
private attachScreenTracksToPeers(includeSystemAudio: boolean): void { private attachScreenTracksToPeers(includeSystemAudio: boolean): void {
this.callbacks.getActivePeers().forEach((peerData, peerId) => { this.callbacks.getActivePeers().forEach((peerData, peerId) => {
if (!this.activeScreenStream) return; if (!this.activeScreenStream)
return;
const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0]; const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0];
if (!screenVideoTrack) return;
if (!screenVideoTrack)
return;
this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`); this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`);
// Use primary video sender/transceiver // Use primary video sender/transceiver
let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO); let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO);
if (!videoSender) { if (!videoSender) {
const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV }); const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV });
videoSender = videoTransceiver.sender; videoSender = videoTransceiver.sender;
peerData.videoSender = videoSender; peerData.videoSender = videoSender;
} else { } else {
const transceivers = peerData.connection.getTransceivers(); const transceivers = peerData.connection.getTransceivers();
const videoTransceiver = transceivers.find(t => t.sender === videoSender); const videoTransceiver = transceivers.find(t => t.sender === videoSender);
if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) { if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) {
videoTransceiver.direction = TRANSCEIVER_SEND_RECV; videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
} }
} }
peerData.screenVideoSender = videoSender; peerData.screenVideoSender = videoSender;
videoSender.replaceTrack(screenVideoTrack) videoSender.replaceTrack(screenVideoTrack)
.then(() => this.logger.info('screen video replaceTrack ok', { peerId })) .then(() => this.logger.info('screen video replaceTrack ok', { peerId }))
@@ -264,15 +292,20 @@ export class ScreenShareManager {
// Audio handling // Audio handling
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
if (includeSystemAudio) { if (includeSystemAudio) {
const combinedTrack = this.combinedAudioStream?.getAudioTracks()[0] || null; const combinedTrack = this.combinedAudioStream?.getAudioTracks()[0] || null;
if (combinedTrack) { if (combinedTrack) {
this.logger.attachTrackDiagnostics(combinedTrack, `combinedAudio:${peerId}`); this.logger.attachTrackDiagnostics(combinedTrack, `combinedAudio:${peerId}`);
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) { if (!audioSender) {
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
audioSender = transceiver.sender; audioSender = transceiver.sender;
} }
peerData.audioSender = audioSender; peerData.audioSender = audioSender;
audioSender.replaceTrack(combinedTrack) audioSender.replaceTrack(combinedTrack)
.then(() => this.logger.info('screen audio(combined) replaceTrack ok', { peerId })) .then(() => this.logger.info('screen audio(combined) replaceTrack ok', { peerId }))
@@ -281,10 +314,13 @@ export class ScreenShareManager {
} else if (micTrack) { } else if (micTrack) {
this.logger.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`); this.logger.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`);
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) { if (!audioSender) {
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
audioSender = transceiver.sender; audioSender = transceiver.sender;
} }
peerData.audioSender = audioSender; peerData.audioSender = audioSender;
audioSender.replaceTrack(micTrack) audioSender.replaceTrack(micTrack)
.then(() => this.logger.info('screen audio(mic) replaceTrack ok', { peerId })) .then(() => this.logger.info('screen audio(mic) replaceTrack ok', { peerId }))
@@ -298,8 +334,10 @@ export class ScreenShareManager {
/** Clean up all resources. */ /** Clean up all resources. */
destroy(): void { destroy(): void {
this.stopScreenShare(); this.stopScreenShare();
if (this.audioMixingContext) { if (this.audioMixingContext) {
try { this.audioMixingContext.close(); } catch { /* ignore */ } try { this.audioMixingContext.close(); } catch { /* ignore */ }
this.audioMixingContext = null; this.audioMixingContext = null;
} }
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, max-statements-per-line */
/** /**
* Manages the WebSocket connection to the signaling server, * Manages the WebSocket connection to the signaling server,
* including automatic reconnection and heartbeats. * including automatic reconnection and heartbeats.
@@ -13,7 +14,7 @@ import {
STATE_HEARTBEAT_INTERVAL_MS, STATE_HEARTBEAT_INTERVAL_MS,
SIGNALING_TYPE_IDENTIFY, SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER, SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_VIEW_SERVER, SIGNALING_TYPE_VIEW_SERVER
} from './webrtc.constants'; } from './webrtc.constants';
export class SignalingManager { export class SignalingManager {
@@ -36,7 +37,7 @@ export class SignalingManager {
private readonly logger: WebRTCLogger, private readonly logger: WebRTCLogger,
private readonly getLastIdentify: () => IdentifyCredentials | null, private readonly getLastIdentify: () => IdentifyCredentials | null,
private readonly getLastJoinedServer: () => JoinedServerInfo | null, private readonly getLastJoinedServer: () => JoinedServerInfo | null,
private readonly getMemberServerIds: () => ReadonlySet<string>, private readonly getMemberServerIds: () => ReadonlySet<string>
) {} ) {}
/** Open (or re-open) a WebSocket to the signaling server. */ /** Open (or re-open) a WebSocket to the signaling server. */
@@ -63,6 +64,7 @@ export class SignalingManager {
this.signalingWebSocket.onmessage = (event) => { this.signalingWebSocket.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
this.messageReceived$.next(message); this.messageReceived$.next(message);
} catch (error) { } catch (error) {
this.logger.error('Failed to parse signaling message', error); this.logger.error('Failed to parse signaling message', error);
@@ -89,18 +91,22 @@ export class SignalingManager {
/** Ensure signaling is connected; try reconnecting if not. */ /** Ensure signaling is connected; try reconnecting if not. */
async ensureConnected(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Promise<boolean> { async ensureConnected(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Promise<boolean> {
if (this.isSocketOpen()) return true; if (this.isSocketOpen())
if (!this.lastSignalingUrl) return false; return true;
if (!this.lastSignalingUrl)
return false;
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
let settled = false; let settled = false;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!settled) { settled = true; resolve(false); } if (!settled) { settled = true; resolve(false); }
}, timeoutMs); }, timeoutMs);
this.connect(this.lastSignalingUrl!).subscribe({ this.connect(this.lastSignalingUrl!).subscribe({
next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } }, next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } },
error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } }, error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } }
}); });
}); });
} }
@@ -111,7 +117,9 @@ export class SignalingManager {
this.logger.error('Signaling socket not connected', new Error('Socket not open')); this.logger.error('Signaling socket not connected', new Error('Socket not open'));
return; return;
} }
const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() }; const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() };
this.signalingWebSocket!.send(JSON.stringify(fullMessage)); this.signalingWebSocket!.send(JSON.stringify(fullMessage));
} }
@@ -121,6 +129,7 @@ export class SignalingManager {
this.logger.error('Signaling socket not connected', new Error('Socket not open')); this.logger.error('Signaling socket not connected', new Error('Socket not open'));
return; return;
} }
this.signalingWebSocket!.send(JSON.stringify(message)); this.signalingWebSocket!.send(JSON.stringify(message));
} }
@@ -128,6 +137,7 @@ export class SignalingManager {
close(): void { close(): void {
this.stopHeartbeat(); this.stopHeartbeat();
this.clearReconnect(); this.clearReconnect();
if (this.signalingWebSocket) { if (this.signalingWebSocket) {
this.signalingWebSocket.close(); this.signalingWebSocket.close();
this.signalingWebSocket = null; this.signalingWebSocket = null;
@@ -147,21 +157,25 @@ export class SignalingManager {
/** Re-identify and rejoin servers after a reconnect. */ /** Re-identify and rejoin servers after a reconnect. */
private reIdentifyAndRejoin(): void { private reIdentifyAndRejoin(): void {
const credentials = this.getLastIdentify(); const credentials = this.getLastIdentify();
if (credentials) { if (credentials) {
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName }); this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName });
} }
const memberIds = this.getMemberServerIds(); const memberIds = this.getMemberServerIds();
if (memberIds.size > 0) { if (memberIds.size > 0) {
memberIds.forEach((serverId) => { memberIds.forEach((serverId) => {
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
}); });
const lastJoined = this.getLastJoinedServer(); const lastJoined = this.getLastJoinedServer();
if (lastJoined) { if (lastJoined) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId });
} }
} else { } else {
const lastJoined = this.getLastJoinedServer(); const lastJoined = this.getLastJoinedServer();
if (lastJoined) { if (lastJoined) {
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId });
} }
@@ -175,18 +189,21 @@ export class SignalingManager {
* No-ops if a timer is already pending or no URL is stored. * No-ops if a timer is already pending or no URL is stored.
*/ */
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return; if (this.signalingReconnectTimer || !this.lastSignalingUrl)
return;
const delay = Math.min( const delay = Math.min(
SIGNALING_RECONNECT_MAX_DELAY_MS, SIGNALING_RECONNECT_MAX_DELAY_MS,
SIGNALING_RECONNECT_BASE_DELAY_MS * Math.pow(2, this.signalingReconnectAttempts), SIGNALING_RECONNECT_BASE_DELAY_MS * Math.pow(2, this.signalingReconnectAttempts)
); );
this.signalingReconnectTimer = setTimeout(() => { this.signalingReconnectTimer = setTimeout(() => {
this.signalingReconnectTimer = null; this.signalingReconnectTimer = null;
this.signalingReconnectAttempts++; this.signalingReconnectAttempts++;
this.logger.info('Attempting to reconnect to signaling...'); this.logger.info('Attempting to reconnect to signaling...');
this.connect(this.lastSignalingUrl!).subscribe({ this.connect(this.lastSignalingUrl!).subscribe({
next: () => { this.signalingReconnectAttempts = 0; }, next: () => { this.signalingReconnectAttempts = 0; },
error: () => { this.scheduleReconnect(); }, error: () => { this.scheduleReconnect(); }
}); });
}, delay); }, delay);
} }
@@ -197,6 +214,7 @@ export class SignalingManager {
clearTimeout(this.signalingReconnectTimer); clearTimeout(this.signalingReconnectTimer);
this.signalingReconnectTimer = null; this.signalingReconnectTimer = null;
} }
this.signalingReconnectAttempts = 0; this.signalingReconnectAttempts = 0;
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable id-length, max-statements-per-line */
/** /**
* VoiceLevelingManager — manages per-speaker automatic gain control * VoiceLevelingManager — manages per-speaker automatic gain control
* pipelines for remote voice streams. * pipelines for remote voice streams.
@@ -70,7 +71,7 @@ export const DEFAULT_VOICE_LEVELING_SETTINGS: VoiceLevelingSettings = {
strength: 'medium', strength: 'medium',
maxGainDb: 12, maxGainDb: 12,
speed: 'medium', speed: 'medium',
noiseGate: false, noiseGate: false
}; };
/** /**
@@ -89,7 +90,6 @@ interface SpeakerPipeline {
/** AudioWorklet module path (served from public/). */ /** AudioWorklet module path (served from public/). */
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js'; const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
/** Processor name — must match `registerProcessor` in the worklet. */ /** Processor name — must match `registerProcessor` in the worklet. */
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor'; const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
@@ -155,6 +155,7 @@ export class VoiceLevelingManager {
async enable(peerId: string, stream: MediaStream): Promise<MediaStream> { async enable(peerId: string, stream: MediaStream): Promise<MediaStream> {
// Reuse existing pipeline if it targets the same stream // Reuse existing pipeline if it targets the same stream
const existing = this.pipelines.get(peerId); const existing = this.pipelines.get(peerId);
if (existing && existing.originalStream === stream) { if (existing && existing.originalStream === stream) {
return existing.destination.stream; return existing.destination.stream;
} }
@@ -173,10 +174,11 @@ export class VoiceLevelingManager {
try { try {
const pipeline = await this._buildPipeline(stream); const pipeline = await this._buildPipeline(stream);
this.pipelines.set(peerId, pipeline); this.pipelines.set(peerId, pipeline);
this.logger.info('VoiceLeveling: pipeline created', { this.logger.info('VoiceLeveling: pipeline created', {
peerId, peerId,
fallback: pipeline.isFallback, fallback: pipeline.isFallback
}); });
return pipeline.destination.stream; return pipeline.destination.stream;
} catch (err) { } catch (err) {
@@ -193,7 +195,10 @@ export class VoiceLevelingManager {
*/ */
disable(peerId: string): void { disable(peerId: string): void {
const pipeline = this.pipelines.get(peerId); const pipeline = this.pipelines.get(peerId);
if (!pipeline) return;
if (!pipeline)
return;
this._disposePipeline(pipeline); this._disposePipeline(pipeline);
this.pipelines.delete(peerId); this.pipelines.delete(peerId);
this.logger.info('VoiceLeveling: pipeline removed', { peerId }); this.logger.info('VoiceLeveling: pipeline removed', { peerId });
@@ -207,15 +212,19 @@ export class VoiceLevelingManager {
setSpeakerVolume(peerId: string, volume: number): void { setSpeakerVolume(peerId: string, volume: number): void {
const pipeline = this.pipelines.get(peerId); const pipeline = this.pipelines.get(peerId);
if (!pipeline) return;
if (!pipeline)
return;
pipeline.gainNode.gain.setValueAtTime( pipeline.gainNode.gain.setValueAtTime(
Math.max(0, Math.min(1, volume)), Math.max(0, Math.min(1, volume)),
pipeline.ctx.currentTime, pipeline.ctx.currentTime
); );
} }
setMasterVolume(volume: number): void { setMasterVolume(volume: number): void {
const clamped = Math.max(0, Math.min(1, volume)); const clamped = Math.max(0, Math.min(1, volume));
this.pipelines.forEach((pipeline) => { this.pipelines.forEach((pipeline) => {
pipeline.gainNode.gain.setValueAtTime(clamped, pipeline.ctx.currentTime); pipeline.gainNode.gain.setValueAtTime(clamped, pipeline.ctx.currentTime);
}); });
@@ -224,9 +233,11 @@ export class VoiceLevelingManager {
/** Tear down all pipelines and release all resources. */ /** Tear down all pipelines and release all resources. */
destroy(): void { destroy(): void {
this.disableAll(); this.disableAll();
if (this._sharedCtx && this._sharedCtx.state !== 'closed') { if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
this._sharedCtx.close().catch(() => { /* best-effort */ }); this._sharedCtx.close().catch(() => { /* best-effort */ });
} }
this._sharedCtx = null; this._sharedCtx = null;
this._workletLoaded = false; this._workletLoaded = false;
this._workletAvailable = null; this._workletAvailable = null;
@@ -243,9 +254,9 @@ export class VoiceLevelingManager {
const source = ctx.createMediaStreamSource(stream); const source = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
gainNode.gain.value = 1.0; gainNode.gain.value = 1.0;
const destination = ctx.createMediaStreamDestination(); const destination = ctx.createMediaStreamDestination();
const workletOk = await this._ensureWorkletLoaded(ctx); const workletOk = await this._ensureWorkletLoaded(ctx);
if (workletOk) { if (workletOk) {
@@ -263,7 +274,7 @@ export class VoiceLevelingManager {
gainNode, gainNode,
destination, destination,
originalStream: stream, originalStream: stream,
isFallback: false, isFallback: false
}; };
this._pushSettingsToPipeline(pipeline); this._pushSettingsToPipeline(pipeline);
@@ -284,7 +295,7 @@ export class VoiceLevelingManager {
gainNode, gainNode,
destination, destination,
originalStream: stream, originalStream: stream,
isFallback: true, isFallback: true
}; };
} }
} }
@@ -300,14 +311,18 @@ export class VoiceLevelingManager {
if (this._sharedCtx && this._sharedCtx.state !== 'closed') { if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
return this._sharedCtx; return this._sharedCtx;
} }
this._sharedCtx = new AudioContext(); this._sharedCtx = new AudioContext();
this._workletLoaded = false; this._workletLoaded = false;
return this._sharedCtx; return this._sharedCtx;
} }
private async _ensureWorkletLoaded(ctx: AudioContext): Promise<boolean> { private async _ensureWorkletLoaded(ctx: AudioContext): Promise<boolean> {
if (this._workletAvailable === false) return false; if (this._workletAvailable === false)
if (this._workletLoaded && this._workletAvailable === true) return true; return false;
if (this._workletLoaded && this._workletAvailable === true)
return true;
try { try {
await ctx.audioWorklet.addModule(WORKLET_MODULE_PATH); await ctx.audioWorklet.addModule(WORKLET_MODULE_PATH);
@@ -324,6 +339,7 @@ export class VoiceLevelingManager {
private _createFallbackCompressor(ctx: AudioContext): DynamicsCompressorNode { private _createFallbackCompressor(ctx: AudioContext): DynamicsCompressorNode {
const compressor = ctx.createDynamicsCompressor(); const compressor = ctx.createDynamicsCompressor();
compressor.threshold.setValueAtTime(-24, ctx.currentTime); compressor.threshold.setValueAtTime(-24, ctx.currentTime);
compressor.knee.setValueAtTime(30, ctx.currentTime); compressor.knee.setValueAtTime(30, ctx.currentTime);
compressor.ratio.setValueAtTime(3, ctx.currentTime); compressor.ratio.setValueAtTime(3, ctx.currentTime);
@@ -342,7 +358,7 @@ export class VoiceLevelingManager {
maxGainDb: this._settings.maxGainDb, maxGainDb: this._settings.maxGainDb,
strength: this._settings.strength, strength: this._settings.strength,
speed: this._settings.speed, speed: this._settings.speed,
noiseGate: this._settings.noiseGate, noiseGate: this._settings.noiseGate
}); });
} }
} }
@@ -351,9 +367,13 @@ export class VoiceLevelingManager {
private _disposePipeline(pipeline: SpeakerPipeline): void { private _disposePipeline(pipeline: SpeakerPipeline): void {
try { pipeline.source.disconnect(); } catch { /* already disconnected */ } try { pipeline.source.disconnect(); } catch { /* already disconnected */ }
try { pipeline.workletNode?.disconnect(); } catch { /* ok */ } try { pipeline.workletNode?.disconnect(); } catch { /* ok */ }
try { pipeline.compressorNode?.disconnect(); } catch { /* ok */ } try { pipeline.compressorNode?.disconnect(); } catch { /* ok */ }
try { pipeline.gainNode.disconnect(); } catch { /* ok */ } try { pipeline.gainNode.disconnect(); } catch { /* ok */ }
try { pipeline.destination.disconnect(); } catch { /* ok */ } try { pipeline.destination.disconnect(); } catch { /* ok */ }
} }
} }

View File

@@ -1,19 +1,24 @@
/* eslint-disable max-statements-per-line */
/** /**
* Lightweight logging utility for the WebRTC subsystem. * Lightweight logging utility for the WebRTC subsystem.
* All log lines are prefixed with `[WebRTC]`. * All log lines are prefixed with `[WebRTC]`.
*/ */
export class WebRTCLogger { export class WebRTCLogger {
constructor(private readonly isEnabled: boolean = true) {} constructor(private readonly isEnabled = true) {}
/** Informational log (only when debug is enabled). */ /** Informational log (only when debug is enabled). */
info(prefix: string, ...args: unknown[]): void { info(prefix: string, ...args: unknown[]): void {
if (!this.isEnabled) return; if (!this.isEnabled)
return;
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
} }
/** Warning log (only when debug is enabled). */ /** Warning log (only when debug is enabled). */
warn(prefix: string, ...args: unknown[]): void { warn(prefix: string, ...args: unknown[]): void {
if (!this.isEnabled) return; if (!this.isEnabled)
return;
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
} }
@@ -23,20 +28,22 @@ export class WebRTCLogger {
name: (err as any)?.name, name: (err as any)?.name,
message: (err as any)?.message, message: (err as any)?.message,
stack: (err as any)?.stack, stack: (err as any)?.stack,
...extra, ...extra
}; };
try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ } try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ }
} }
/** Attach lifecycle event listeners to a track for debugging. */ /** Attach lifecycle event listeners to a track for debugging. */
attachTrackDiagnostics(track: MediaStreamTrack, label: string): void { attachTrackDiagnostics(track: MediaStreamTrack, label: string): void {
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings; const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
this.info(`Track attached: ${label}`, { this.info(`Track attached: ${label}`, {
id: track.id, id: track.id,
kind: track.kind, kind: track.kind,
readyState: track.readyState, readyState: track.readyState,
contentHint: track.contentHint, contentHint: track.contentHint,
settings, settings
}); });
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind })); track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind }));
@@ -50,13 +57,15 @@ export class WebRTCLogger {
this.warn(`Stream missing: ${label}`); this.warn(`Stream missing: ${label}`);
return; return;
} }
const audioTracks = stream.getAudioTracks(); const audioTracks = stream.getAudioTracks();
const videoTracks = stream.getVideoTracks(); const videoTracks = stream.getVideoTracks();
this.info(`Stream ready: ${label}`, { this.info(`Stream ready: ${label}`, {
id: (stream as any).id, id: (stream as any).id,
audioTrackCount: audioTracks.length, audioTrackCount: audioTracks.length,
videoTrackCount: videoTracks.length, videoTrackCount: videoTracks.length,
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind })), allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind }))
}); });
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`)); audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`)); videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));

View File

@@ -8,7 +8,7 @@ export const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' }, { urls: 'stun:stun3.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:19302' }, { urls: 'stun:stun4.l.google.com:19302' }
]; ];
/** Base delay (ms) for exponential backoff on signaling reconnect */ /** Base delay (ms) for exponential backoff on signaling reconnect */
@@ -51,7 +51,7 @@ export const KBPS_TO_BPS = 1_000;
export const LATENCY_PROFILE_BITRATES = { export const LATENCY_PROFILE_BITRATES = {
low: 64_000, low: 64_000,
balanced: 96_000, balanced: 96_000,
high: 128_000, high: 128_000
} as const; } as const;
export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES; export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES;

View File

@@ -9,6 +9,7 @@
<!-- Tabs --> <!-- Tabs -->
<div class="flex border-b border-border"> <div class="flex border-b border-border">
<button <button
type="button"
(click)="activeTab.set('settings')" (click)="activeTab.set('settings')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors" class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'settings'" [class.text-primary]="activeTab() === 'settings'"
@@ -20,6 +21,7 @@
Settings Settings
</button> </button>
<button <button
type="button"
(click)="activeTab.set('members')" (click)="activeTab.set('members')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors" class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'members'" [class.text-primary]="activeTab() === 'members'"
@@ -31,6 +33,7 @@
Members Members
</button> </button>
<button <button
type="button"
(click)="activeTab.set('bans')" (click)="activeTab.set('bans')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors" class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'bans'" [class.text-primary]="activeTab() === 'bans'"
@@ -42,6 +45,7 @@
Bans Bans
</button> </button>
<button <button
type="button"
(click)="activeTab.set('permissions')" (click)="activeTab.set('permissions')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors" class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'permissions'" [class.text-primary]="activeTab() === 'permissions'"
@@ -63,9 +67,10 @@
<!-- Room Name --> <!-- Room Name -->
<div> <div>
<label class="block text-sm text-muted-foreground mb-1">Room Name</label> <label for="room-name-input" class="block text-sm text-muted-foreground mb-1">Room Name</label>
<input <input
type="text" type="text"
id="room-name-input"
[(ngModel)]="roomName" [(ngModel)]="roomName"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/> />
@@ -73,8 +78,9 @@
<!-- Room Description --> <!-- Room Description -->
<div> <div>
<label class="block text-sm text-muted-foreground mb-1">Description</label> <label for="room-description-input" class="block text-sm text-muted-foreground mb-1">Description</label>
<textarea <textarea
id="room-description-input"
[(ngModel)]="roomDescription" [(ngModel)]="roomDescription"
rows="3" rows="3"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none" class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
@@ -88,6 +94,7 @@
<p class="text-xs text-muted-foreground">Require approval to join</p> <p class="text-xs text-muted-foreground">Require approval to join</p>
</div> </div>
<button <button
type="button"
(click)="togglePrivate()" (click)="togglePrivate()"
class="p-2 rounded-lg transition-colors" class="p-2 rounded-lg transition-colors"
[class.bg-primary]="isPrivate()" [class.bg-primary]="isPrivate()"
@@ -105,9 +112,10 @@
<!-- Max Users --> <!-- Max Users -->
<div> <div>
<label class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label> <label for="max-users-input" class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
<input <input
type="number" type="number"
id="max-users-input"
[(ngModel)]="maxUsers" [(ngModel)]="maxUsers"
min="0" min="0"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
@@ -116,6 +124,7 @@
<!-- Save Button --> <!-- Save Button -->
<button <button
type="button"
(click)="saveSettings()" (click)="saveSettings()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2" class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
> >
@@ -127,6 +136,7 @@
<div class="pt-4 border-t border-border"> <div class="pt-4 border-t border-border">
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3> <h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
<button <button
type="button"
(click)="confirmDeleteRoom()" (click)="confirmDeleteRoom()"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2" class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
> >
@@ -173,6 +183,7 @@
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
<button <button
type="button"
(click)="kickMember(user)" (click)="kickMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors" class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick" title="Kick"
@@ -180,6 +191,7 @@
<ng-icon name="lucideUserX" class="w-4 h-4" /> <ng-icon name="lucideUserX" class="w-4 h-4" />
</button> </button>
<button <button
type="button"
(click)="banMember(user)" (click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors" class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban" title="Ban"
@@ -225,6 +237,7 @@
} }
</div> </div>
<button <button
type="button"
(click)="unbanUser(ban)" (click)="unbanUser(ban)"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground" class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
> >
@@ -346,6 +359,7 @@
<!-- Save Permissions --> <!-- Save Permissions -->
<button <button
type="button"
(click)="savePermissions()" (click)="savePermissions()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2" class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
> >

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -13,7 +14,7 @@ import {
lucideCheck, lucideCheck,
lucideX, lucideX,
lucideLock, lucideLock,
lucideUnlock, lucideUnlock
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
@@ -23,9 +24,9 @@ import {
selectBannedUsers, selectBannedUsers,
selectIsCurrentUserAdmin, selectIsCurrentUserAdmin,
selectCurrentUser, selectCurrentUser,
selectOnlineUsers, selectOnlineUsers
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { BanEntry, Room, User } from '../../../core/models'; import { BanEntry, User } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
@@ -46,10 +47,10 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
lucideCheck, lucideCheck,
lucideX, lucideX,
lucideLock, lucideLock,
lucideUnlock, lucideUnlock
}), })
], ],
templateUrl: './admin-panel.component.html', templateUrl: './admin-panel.component.html'
}) })
/** /**
* Admin panel for managing room settings, members, bans, and permissions. * Admin panel for managing room settings, members, bans, and permissions.
@@ -87,12 +88,14 @@ export class AdminPanelComponent {
constructor() { constructor() {
// Initialize from current room // Initialize from current room
const room = this.currentRoom(); const room = this.currentRoom();
if (room) { if (room) {
this.roomName = room.name; this.roomName = room.name;
this.roomDescription = room.description || ''; this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate); this.isPrivate.set(room.isPrivate);
this.maxUsers = room.maxUsers || 0; this.maxUsers = room.maxUsers || 0;
const perms = room.permissions || {}; const perms = room.permissions || {};
this.allowVoice = perms.allowVoice !== false; this.allowVoice = perms.allowVoice !== false;
this.allowScreenShare = perms.allowScreenShare !== false; this.allowScreenShare = perms.allowScreenShare !== false;
this.allowFileUploads = perms.allowFileUploads !== false; this.allowFileUploads = perms.allowFileUploads !== false;
@@ -112,7 +115,9 @@ export class AdminPanelComponent {
/** Save the current room name, description, privacy, and max-user settings. */ /** Save the current room name, description, privacy, and max-user settings. */
saveSettings(): void { saveSettings(): void {
const room = this.currentRoom(); const room = this.currentRoom();
if (!room) return;
if (!room)
return;
this.store.dispatch( this.store.dispatch(
RoomsActions.updateRoom({ RoomsActions.updateRoom({
@@ -121,8 +126,8 @@ export class AdminPanelComponent {
name: this.roomName, name: this.roomName,
description: this.roomDescription, description: this.roomDescription,
isPrivate: this.isPrivate(), isPrivate: this.isPrivate(),
maxUsers: this.maxUsers, maxUsers: this.maxUsers
}, }
}) })
); );
} }
@@ -130,7 +135,9 @@ export class AdminPanelComponent {
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */ /** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
savePermissions(): void { savePermissions(): void {
const room = this.currentRoom(); const room = this.currentRoom();
if (!room) return;
if (!room)
return;
this.store.dispatch( this.store.dispatch(
RoomsActions.updateRoomPermissions({ RoomsActions.updateRoomPermissions({
@@ -143,8 +150,8 @@ export class AdminPanelComponent {
adminsManageRooms: this.adminsManageRooms, adminsManageRooms: this.adminsManageRooms,
moderatorsManageRooms: this.moderatorsManageRooms, moderatorsManageRooms: this.moderatorsManageRooms,
adminsManageIcon: this.adminsManageIcon, adminsManageIcon: this.adminsManageIcon,
moderatorsManageIcon: this.moderatorsManageIcon, moderatorsManageIcon: this.moderatorsManageIcon
}, }
}) })
); );
} }
@@ -162,7 +169,9 @@ export class AdminPanelComponent {
/** Delete the current room after confirmation. */ /** Delete the current room after confirmation. */
deleteRoom(): void { deleteRoom(): void {
const room = this.currentRoom(); const room = this.currentRoom();
if (!room) return;
if (!room)
return;
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id })); this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
this.showDeleteConfirm.set(false); this.showDeleteConfirm.set(false);
@@ -171,6 +180,7 @@ export class AdminPanelComponent {
/** Format a ban expiry timestamp into a human-readable date/time string. */ /** Format a ban expiry timestamp into a human-readable date/time string. */
formatExpiry(timestamp: number): string { formatExpiry(timestamp: number): string {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
@@ -178,6 +188,7 @@ export class AdminPanelComponent {
/** Return online users excluding the current user (for the members list). */ /** Return online users excluding the current user (for the members list). */
membersFiltered(): User[] { membersFiltered(): User[] {
const me = this.currentUser(); const me = this.currentUser();
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId); return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
} }
@@ -187,7 +198,7 @@ export class AdminPanelComponent {
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'role-change', type: 'role-change',
targetUserId: user.id, targetUserId: user.id,
role, role
}); });
} }
@@ -197,7 +208,7 @@ export class AdminPanelComponent {
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'kick', type: 'kick',
targetUserId: user.id, targetUserId: user.id,
kickedBy: this.currentUser()?.id, kickedBy: this.currentUser()?.id
}); });
} }
@@ -207,7 +218,7 @@ export class AdminPanelComponent {
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'ban', type: 'ban',
targetUserId: user.id, targetUserId: user.id,
bannedBy: this.currentUser()?.id, bannedBy: this.currentUser()?.id
}); });
} }
} }

View File

@@ -7,25 +7,28 @@
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs text-muted-foreground mb-1">Username</label> <label for="login-username" class="block text-xs text-muted-foreground mb-1">Username</label>
<input <input
[(ngModel)]="username" [(ngModel)]="username"
type="text" type="text"
id="login-username"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-muted-foreground mb-1">Password</label> <label for="login-password" class="block text-xs text-muted-foreground mb-1">Password</label>
<input <input
[(ngModel)]="password" [(ngModel)]="password"
type="password" type="password"
id="login-password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-muted-foreground mb-1">Server App</label> <label for="login-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
<select <select
[(ngModel)]="serverId" [(ngModel)]="serverId"
id="login-server"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
> >
@for (s of servers(); track s.id) { @for (s of servers(); track s.id) {
@@ -38,12 +41,20 @@
} }
<button <button
(click)="submit()" (click)="submit()"
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
> >
Login Login
</button> </button>
<div class="text-xs text-muted-foreground text-center mt-2"> <div class="text-xs text-muted-foreground text-center mt-2">
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a> No account?
<button
type="button"
(click)="goRegister()"
class="text-primary hover:underline"
>
Register
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [provideIcons({ lucideLogIn })], viewProviders: [provideIcons({ lucideLogIn })],
templateUrl: './login.component.html', templateUrl: './login.component.html'
}) })
/** /**
* Login form allowing existing users to authenticate against a selected server. * Login form allowing existing users to authenticate against a selected server.
@@ -41,9 +42,12 @@ export class LoginComponent {
submit() { submit() {
this.error.set(null); this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id; const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({ this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
next: (resp) => { next: (resp) => {
if (sid) this.serversSvc.setActiveServer(sid); if (sid)
this.serversSvc.setActiveServer(sid);
const user: User = { const user: User = {
id: resp.id, id: resp.id,
oderId: resp.id, oderId: resp.id,
@@ -51,15 +55,17 @@ export class LoginComponent {
displayName: resp.displayName, displayName: resp.displayName,
status: 'online', status: 'online',
role: 'member', role: 'member',
joinedAt: Date.now(), joinedAt: Date.now()
}; };
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user })); this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']); this.router.navigate(['/search']);
}, },
error: (err) => { error: (err) => {
this.error.set(err?.error?.error || 'Login failed'); this.error.set(err?.error?.error || 'Login failed');
}, }
}); });
} }

View File

@@ -7,33 +7,37 @@
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs text-muted-foreground mb-1">Username</label> <label for="register-username" class="block text-xs text-muted-foreground mb-1">Username</label>
<input <input
[(ngModel)]="username" [(ngModel)]="username"
type="text" type="text"
id="register-username"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-muted-foreground mb-1">Display Name</label> <label for="register-display-name" class="block text-xs text-muted-foreground mb-1">Display Name</label>
<input <input
[(ngModel)]="displayName" [(ngModel)]="displayName"
type="text" type="text"
id="register-display-name"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-muted-foreground mb-1">Password</label> <label for="register-password" class="block text-xs text-muted-foreground mb-1">Password</label>
<input <input
[(ngModel)]="password" [(ngModel)]="password"
type="password" type="password"
id="register-password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-muted-foreground mb-1">Server App</label> <label for="register-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
<select <select
[(ngModel)]="serverId" [(ngModel)]="serverId"
id="register-server"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
> >
@for (s of servers(); track s.id) { @for (s of servers(); track s.id) {
@@ -46,12 +50,20 @@
} }
<button <button
(click)="submit()" (click)="submit()"
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
> >
Create Account Create Account
</button> </button>
<div class="text-xs text-muted-foreground text-center mt-2"> <div class="text-xs text-muted-foreground text-center mt-2">
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a> Have an account?
<button
type="button"
(click)="goLogin()"
class="text-primary hover:underline"
>
Login
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [provideIcons({ lucideUserPlus })], viewProviders: [provideIcons({ lucideUserPlus })],
templateUrl: './register.component.html', templateUrl: './register.component.html'
}) })
/** /**
* Registration form allowing new users to create an account on a selected server. * Registration form allowing new users to create an account on a selected server.
@@ -42,9 +43,12 @@ export class RegisterComponent {
submit() { submit() {
this.error.set(null); this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id; const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({ this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
next: (resp) => { next: (resp) => {
if (sid) this.serversSvc.setActiveServer(sid); if (sid)
this.serversSvc.setActiveServer(sid);
const user: User = { const user: User = {
id: resp.id, id: resp.id,
oderId: resp.id, oderId: resp.id,
@@ -52,15 +56,17 @@ export class RegisterComponent {
displayName: resp.displayName, displayName: resp.displayName,
status: 'online', status: 'online',
role: 'member', role: 'member',
joinedAt: Date.now(), joinedAt: Date.now()
}; };
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user })); this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']); this.router.navigate(['/search']);
}, },
error: (err) => { error: (err) => {
this.error.set(err?.error?.error || 'Registration failed'); this.error.set(err?.error?.error || 'Registration failed');
}, }
}); });
} }

View File

@@ -6,11 +6,11 @@
<span class="text-foreground">{{ user()?.displayName }}</span> <span class="text-foreground">{{ user()?.displayName }}</span>
</div> </div>
} @else { } @else {
<button (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"> <button type="button" (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1">
<ng-icon name="lucideLogIn" class="w-4 h-4" /> <ng-icon name="lucideLogIn" class="w-4 h-4" />
Login Login
</button> </button>
<button (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"> <button type="button" (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1">
<ng-icon name="lucideUserPlus" class="w-4 h-4" /> <ng-icon name="lucideUserPlus" class="w-4 h-4" />
Register Register
</button> </button>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -11,7 +12,7 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
standalone: true, standalone: true,
imports: [CommonModule, NgIcon], imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })], viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
templateUrl: './user-bar.component.html', templateUrl: './user-bar.component.html'
}) })
/** /**
* Compact user status bar showing the current user with login/register navigation links. * Compact user status bar showing the current user with login/register navigation links.

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
<div class="chat-layout relative h-full"> <div class="chat-layout relative h-full">
<!-- Messages List --> <!-- Messages List -->
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()"> <div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-len, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -16,7 +17,7 @@ import {
lucideDownload, lucideDownload,
lucideExpand, lucideExpand,
lucideImage, lucideImage,
lucideCopy, lucideCopy
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { MessagesActions } from '../../../store/messages/messages.actions'; import { MessagesActions } from '../../../store/messages/messages.actions';
@@ -25,7 +26,6 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/user
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models'; import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
import { Subscription } from 'rxjs';
import { ServerDirectoryService } from '../../../core/services/server-directory.service'; import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared'; import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component'; import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
@@ -55,16 +55,15 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
lucideDownload, lucideDownload,
lucideExpand, lucideExpand,
lucideImage, lucideImage,
lucideCopy, lucideCopy
}), })
], ],
templateUrl: './chat-messages.component.html', templateUrl: './chat-messages.component.html',
styleUrls: ['./chat-messages.component.scss'], styleUrls: ['./chat-messages.component.scss'],
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
host: { host: {
'(document:keydown)': 'onDocKeydown($event)', '(document:keydown)': 'onDocKeydown($event)',
'(document:keyup)': 'onDocKeyup($event)', '(document:keyup)': 'onDocKeyup($event)'
}, }
}) })
/** /**
* Real-time chat messages view with infinite scroll, markdown rendering, * Real-time chat messages view with infinite scroll, markdown rendering,
@@ -100,6 +99,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private allChannelMessages = computed(() => { private allChannelMessages = computed(() => {
const channelId = this.activeChannelId(); const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id; const roomId = this.currentRoom()?.id;
return this.allMessages().filter(message => return this.allMessages().filter(message =>
message.roomId === roomId && (message.channelId || 'general') === channelId message.roomId === roomId && (message.channelId || 'general') === channelId
); );
@@ -109,7 +109,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
messages = computed(() => { messages = computed(() => {
const all = this.allChannelMessages(); const all = this.allChannelMessages();
const limit = this.displayLimit(); const limit = this.displayLimit();
if (all.length <= limit) return all;
if (all.length <= limit)
return all;
return all.slice(all.length - limit); return all.slice(all.length - limit);
}); });
@@ -191,6 +194,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private onMessagesChanged = effect(() => { private onMessagesChanged = effect(() => {
const currentCount = this.totalChannelMessagesLength(); const currentCount = this.totalChannelMessagesLength();
const el = this.messagesContainer?.nativeElement; const el = this.messagesContainer?.nativeElement;
if (!el) { if (!el) {
this.lastMessageCount = currentCount; this.lastMessageCount = currentCount;
return; return;
@@ -204,6 +208,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const newMessages = currentCount > this.lastMessageCount; const newMessages = currentCount > this.lastMessageCount;
if (newMessages) { if (newMessages) {
if (distanceFromBottom <= 300) { if (distanceFromBottom <= 300) {
// Smooth auto-scroll only when near bottom; schedule after render // Smooth auto-scroll only when near bottom; schedule after render
@@ -214,12 +219,15 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
queueMicrotask(() => this.showNewMessagesBar.set(true)); queueMicrotask(() => this.showNewMessagesBar.set(true));
} }
} }
this.lastMessageCount = currentCount; this.lastMessageCount = currentCount;
}); });
ngAfterViewChecked(): void { ngAfterViewChecked(): void {
const el = this.messagesContainer?.nativeElement; const el = this.messagesContainer?.nativeElement;
if (!el) return;
if (!el)
return;
// First render after connect: scroll to bottom instantly (no animation) // First render after connect: scroll to bottom instantly (no animation)
// Only proceed once messages are actually rendered in the DOM // Only proceed once messages are actually rendered in the DOM
@@ -238,8 +246,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.initialScrollPending = false; this.initialScrollPending = false;
this.lastMessageCount = 0; this.lastMessageCount = 0;
} }
return; return;
} }
this.updateScrollPadding(); this.updateScrollPadding();
} }
@@ -257,18 +267,22 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.boundOnKeydown = (event: KeyboardEvent) => { this.boundOnKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; } if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
if (this.lightboxAttachment()) { this.closeLightbox(); return; } if (this.lightboxAttachment()) { this.closeLightbox(); return; }
} }
}; };
document.addEventListener('keydown', this.boundOnKeydown); document.addEventListener('keydown', this.boundOnKeydown);
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.stopInitialScrollWatch(); this.stopInitialScrollWatch();
if (this.nowTimer) { if (this.nowTimer) {
clearInterval(this.nowTimer); clearInterval(this.nowTimer);
this.nowTimer = null; this.nowTimer = null;
} }
if (this.boundOnKeydown) { if (this.boundOnKeydown) {
document.removeEventListener('keydown', this.boundOnKeydown); document.removeEventListener('keydown', this.boundOnKeydown);
} }
@@ -277,7 +291,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Send the current message content (with optional attachments) and reset the input. */ /** Send the current message content (with optional attachments) and reset the input. */
sendMessage(): void { sendMessage(): void {
const raw = this.messageContent.trim(); const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0) return;
if (!raw && this.pendingFiles.length === 0)
return;
const content = this.markdown.appendImageMarkdown(raw); const content = this.markdown.appendImageMarkdown(raw);
@@ -285,7 +301,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
MessagesActions.sendMessage({ MessagesActions.sendMessage({
content, content,
replyToId: this.replyTo()?.id, replyToId: this.replyTo()?.id,
channelId: this.activeChannelId(), channelId: this.activeChannelId()
}) })
); );
@@ -305,6 +321,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Throttle and broadcast a typing indicator when the user types. */ /** Throttle and broadcast a typing indicator when the user types. */
onInputChange(): void { onInputChange(): void {
const now = Date.now(); const now = Date.now();
if (now - this.lastTypingSentAt > 1000) { // throttle typing events if (now - this.lastTypingSentAt > 1000) { // throttle typing events
try { try {
this.webrtc.sendRawMessage({ type: 'typing' }); this.webrtc.sendRawMessage({ type: 'typing' });
@@ -321,12 +338,13 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Save the edited message content and exit edit mode. */ /** Save the edited message content and exit edit mode. */
saveEdit(messageId: string): void { saveEdit(messageId: string): void {
if (!this.editContent.trim()) return; if (!this.editContent.trim())
return;
this.store.dispatch( this.store.dispatch(
MessagesActions.editMessage({ MessagesActions.editMessage({
messageId, messageId,
content: this.editContent.trim(), content: this.editContent.trim()
}) })
); );
@@ -366,8 +384,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Smooth-scroll to a specific message element and briefly highlight it. */ /** Smooth-scroll to a specific message element and briefly highlight it. */
scrollToMessage(messageId: string): void { scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement; const container = this.messagesContainer?.nativeElement;
if (!container) return;
if (!container)
return;
const el = container.querySelector(`[data-message-id="${messageId}"]`); const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (el) { if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-primary/10'); el.classList.add('bg-primary/10');
@@ -393,7 +415,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const message = this.messages().find((msg) => msg.id === messageId); const message = this.messages().find((msg) => msg.id === messageId);
const currentUserId = this.currentUser()?.id; const currentUserId = this.currentUser()?.id;
if (!message || !currentUserId) return; if (!message || !currentUserId)
return;
const hasReacted = message.reactions.some( const hasReacted = message.reactions.some(
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId (reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
@@ -418,15 +441,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
message.reactions.forEach((reaction) => { message.reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false }; const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
groups.set(reaction.emoji, { groups.set(reaction.emoji, {
count: existing.count + 1, count: existing.count + 1,
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId, hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId
}); });
}); });
return Array.from(groups.entries()).map(([emoji, data]) => ({ return Array.from(groups.entries()).map(([emoji, data]) => ({
emoji, emoji,
...data, ...data
})); }));
} }
@@ -435,7 +459,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(this.nowRef); const now = new Date(this.nowRef);
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker // Compare calendar days (midnight-aligned) to avoid NG0100 flicker
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24)); const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
@@ -454,6 +477,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private scrollToBottom(): void { private scrollToBottom(): void {
if (this.messagesContainer) { if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement; const el = this.messagesContainer.nativeElement;
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
this.shouldScrollToBottom = false; this.shouldScrollToBottom = false;
} }
@@ -470,11 +494,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.stopInitialScrollWatch(); // clean up any prior watcher this.stopInitialScrollWatch(); // clean up any prior watcher
const el = this.messagesContainer?.nativeElement; const el = this.messagesContainer?.nativeElement;
if (!el) return;
if (!el)
return;
const snap = () => { const snap = () => {
if (this.messagesContainer) { if (this.messagesContainer) {
const e = this.messagesContainer.nativeElement; const e = this.messagesContainer.nativeElement;
this.isAutoScrolling = true; this.isAutoScrolling = true;
e.scrollTop = e.scrollHeight; e.scrollTop = e.scrollHeight;
// Clear flag after browser fires the synchronous scroll event // Clear flag after browser fires the synchronous scroll event
@@ -490,7 +517,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ['src'], // img src swaps attributeFilter: ['src'] // img src swaps
}); });
// 2. Capture-phase 'load' listener catches images finishing load // 2. Capture-phase 'load' listener catches images finishing load
@@ -506,10 +533,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.initialScrollObserver.disconnect(); this.initialScrollObserver.disconnect();
this.initialScrollObserver = null; this.initialScrollObserver = null;
} }
if (this.boundOnImageLoad && this.messagesContainer) { if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true); this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null; this.boundOnImageLoad = null;
} }
if (this.initialScrollTimer) { if (this.initialScrollTimer) {
clearTimeout(this.initialScrollTimer); clearTimeout(this.initialScrollTimer);
this.initialScrollTimer = null; this.initialScrollTimer = null;
@@ -519,12 +548,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private scrollToBottomSmooth(): void { private scrollToBottomSmooth(): void {
if (this.messagesContainer) { if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement; const el = this.messagesContainer.nativeElement;
try { try {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} catch { } catch {
// Fallback if smooth not supported // Fallback if smooth not supported
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
} }
this.shouldScrollToBottom = false; this.shouldScrollToBottom = false;
} }
} }
@@ -538,21 +569,28 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */ /** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */
onScroll(): void { onScroll(): void {
if (!this.messagesContainer) return; if (!this.messagesContainer)
return;
// Ignore scroll events caused by programmatic snap-to-bottom // Ignore scroll events caused by programmatic snap-to-bottom
if (this.isAutoScrolling) return; if (this.isAutoScrolling)
return;
const el = this.messagesContainer.nativeElement; const el = this.messagesContainer.nativeElement;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
this.shouldScrollToBottom = distanceFromBottom <= 300; this.shouldScrollToBottom = distanceFromBottom <= 300;
if (this.shouldScrollToBottom) { if (this.shouldScrollToBottom) {
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} }
// Any user-initiated scroll during the initial load period // Any user-initiated scroll during the initial load period
// immediately hands control back to the user // immediately hands control back to the user
if (this.initialScrollObserver) { if (this.initialScrollObserver) {
this.stopInitialScrollWatch(); this.stopInitialScrollWatch();
} }
// Infinite scroll upwards — load older messages when near the top // Infinite scroll upwards — load older messages when near the top
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) { if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore(); this.loadMore();
@@ -561,7 +599,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Load older messages by expanding the display window, preserving scroll position */ /** Load older messages by expanding the display window, preserving scroll position */
loadMore(): void { loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages()) return; if (this.loadingMore() || !this.hasMoreMessages())
return;
this.loadingMore.set(true); this.loadingMore.set(true);
const el = this.messagesContainer?.nativeElement; const el = this.messagesContainer?.nativeElement;
@@ -574,8 +614,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (el) { if (el) {
const newScrollHeight = el.scrollHeight; const newScrollHeight = el.scrollHeight;
el.scrollTop += newScrollHeight - prevScrollHeight; el.scrollTop += newScrollHeight - prevScrollHeight;
} }
this.loadingMore.set(false); this.loadingMore.set(false);
}); });
}); });
@@ -585,21 +627,25 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */ /** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */
onEnter(evt: Event): void { onEnter(evt: Event): void {
const keyEvent = evt as KeyboardEvent; const keyEvent = evt as KeyboardEvent;
if (keyEvent.shiftKey) { if (keyEvent.shiftKey) {
// allow newline // allow newline
return; return;
} }
keyEvent.preventDefault(); keyEvent.preventDefault();
this.sendMessage(); this.sendMessage();
} }
private getSelection(): { start: number; end: number } { private getSelection(): { start: number; end: number } {
const el = this.messageInputRef?.nativeElement; const el = this.messageInputRef?.nativeElement;
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length }; return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
} }
private setSelection(start: number, end: number): void { private setSelection(start: number, end: number): void {
const el = this.messageInputRef?.nativeElement; const el = this.messageInputRef?.nativeElement;
if (el) { if (el) {
el.selectionStart = start; el.selectionStart = start;
el.selectionEnd = end; el.selectionEnd = end;
@@ -610,6 +656,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */ /** Wrap selected text in an inline markdown token (bold, italic, etc.). */
applyInline(token: string): void { applyInline(token: string): void {
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token); const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -617,6 +664,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */ /** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
applyPrefix(prefix: string): void { applyPrefix(prefix: string): void {
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix); const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -624,6 +672,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Insert a markdown heading at the given level around the current selection. */ /** Insert a markdown heading at the given level around the current selection. */
applyHeading(level: number): void { applyHeading(level: number): void {
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level); const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -631,6 +680,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Convert selected lines into a numbered markdown list. */ /** Convert selected lines into a numbered markdown list. */
applyOrderedList(): void { applyOrderedList(): void {
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection()); const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -638,6 +688,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Wrap the selection in a fenced markdown code block. */ /** Wrap the selection in a fenced markdown code block. */
applyCodeBlock(): void { applyCodeBlock(): void {
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection()); const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -645,6 +696,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Insert a markdown link around the current selection. */ /** Insert a markdown link around the current selection. */
applyLink(): void { applyLink(): void {
const result = this.markdown.applyLink(this.messageContent, this.getSelection()); const result = this.markdown.applyLink(this.messageContent, this.getSelection());
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -652,6 +704,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Insert a markdown image embed around the current selection. */ /** Insert a markdown image embed around the current selection. */
applyImage(): void { applyImage(): void {
const result = this.markdown.applyImage(this.messageContent, this.getSelection()); const result = this.markdown.applyImage(this.messageContent, this.getSelection());
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -659,6 +712,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Insert a horizontal rule at the cursor position. */ /** Insert a horizontal rule at the cursor position. */
applyHorizontalRule(): void { applyHorizontalRule(): void {
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection()); const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
this.messageContent = result.text; this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd); this.setSelection(result.selectionStart, result.selectionEnd);
} }
@@ -687,12 +741,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
evt.preventDefault(); evt.preventDefault();
const files: File[] = []; const files: File[] = [];
const items = evt.dataTransfer?.items; const items = evt.dataTransfer?.items;
if (items && items.length) { if (items && items.length) {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile(); const file = item.getAsFile();
if (file) files.push(file);
if (file)
files.push(file);
} }
} }
} else if (evt.dataTransfer?.files?.length) { } else if (evt.dataTransfer?.files?.length) {
@@ -700,6 +758,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
files.push(evt.dataTransfer.files[i]); files.push(evt.dataTransfer.files[i]);
} }
} }
files.forEach((file) => this.pendingFiles.push(file)); files.forEach((file) => this.pendingFiles.push(file));
// Keep toolbar visible so user sees options // Keep toolbar visible so user sees options
this.toolbarVisible.set(true); this.toolbarVisible.set(true);
@@ -714,25 +773,34 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */ /** Format a byte count into a human-readable size string (B, KB, MB, GB). */
formatBytes(bytes: number): string { formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes; let size = bytes;
let i = 0; let i = 0;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(1)} ${units[i]}`; return `${size.toFixed(1)} ${units[i]}`;
} }
/** Format a transfer speed in bytes/second to a human-readable string. */ /** Format a transfer speed in bytes/second to a human-readable string. */
formatSpeed(bps?: number): string { formatSpeed(bps?: number): string {
if (!bps || bps <= 0) return '0 B/s'; if (!bps || bps <= 0)
return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let speed = bps; let speed = bps;
let i = 0; let i = 0;
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; } while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`; return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
} }
/** Remove a pending file from the upload queue. */ /** Remove a pending file from the upload queue. */
removePendingFile(file: File): void { removePendingFile(file: File): void {
const idx = this.pendingFiles.findIndex((pending) => pending === file); const idx = this.pendingFiles.findIndex((pending) => pending === file);
if (idx >= 0) { if (idx >= 0) {
this.pendingFiles.splice(idx, 1); this.pendingFiles.splice(idx, 1);
} }
@@ -740,8 +808,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Download a completed attachment to the user's device. */ /** Download a completed attachment to the user's device. */
downloadAttachment(att: Attachment): void { downloadAttachment(att: Attachment): void {
if (!att.available || !att.objectUrl) return; if (!att.available || !att.objectUrl)
return;
const a = document.createElement('a'); const a = document.createElement('a');
a.href = att.objectUrl; a.href = att.objectUrl;
a.download = att.filename; a.download = att.filename;
document.body.appendChild(a); document.body.appendChild(a);
@@ -762,6 +833,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Check whether the current user is the original uploader of an attachment. */ /** Check whether the current user is the original uploader of an attachment. */
isUploader(att: Attachment): boolean { isUploader(att: Attachment): boolean {
const myUserId = this.currentUser()?.id; const myUserId = this.currentUser()?.id;
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId; return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
} }
@@ -794,14 +866,18 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Copy an image attachment to the system clipboard as PNG. */ /** Copy an image attachment to the system clipboard as PNG. */
async copyImageToClipboard(att: Attachment): Promise<void> { async copyImageToClipboard(att: Attachment): Promise<void> {
this.closeImageContextMenu(); this.closeImageContextMenu();
if (!att.objectUrl) return;
if (!att.objectUrl)
return;
try { try {
const resp = await fetch(att.objectUrl); const resp = await fetch(att.objectUrl);
const blob = await resp.blob(); const blob = await resp.blob();
// Convert to PNG for clipboard compatibility // Convert to PNG for clipboard compatibility
const pngBlob = await this.convertToPng(blob); const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob }), new ClipboardItem({ 'image/png': pngBlob })
]); ]);
} catch (_error) { } catch (_error) {
// Failed to copy image to clipboard // Failed to copy image to clipboard
@@ -814,22 +890,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
resolve(blob); resolve(blob);
return; return;
} }
const img = new Image(); const img = new Image();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
img.onload = () => { img.onload = () => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth; canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight; canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) { reject(new Error('Canvas not supported')); return; } if (!ctx) { reject(new Error('Canvas not supported')); return; }
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => { canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
if (pngBlob) resolve(pngBlob);
else reject(new Error('PNG conversion failed')); if (pngBlob)
resolve(pngBlob);
else
reject(new Error('PNG conversion failed'));
}, 'image/png'); }, 'image/png');
}; };
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
img.src = url; img.src = url;
}); });
} }
@@ -841,14 +927,20 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private attachFilesToLastOwnMessage(content: string): void { private attachFilesToLastOwnMessage(content: string): void {
const me = this.currentUser()?.id; const me = this.currentUser()?.id;
if (!me) return;
if (!me)
return;
const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted); const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted);
if (!msg) { if (!msg) {
// Retry shortly until message appears // Retry shortly until message appears
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150); setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
return; return;
} }
const uploaderPeerId = this.currentUser()?.id || undefined; const uploaderPeerId = this.currentUser()?.id || undefined;
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId); this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
this.pendingFiles = []; this.pendingFiles = [];
} }
@@ -856,7 +948,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */ /** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void { autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement; const el = this.messageInputRef?.nativeElement;
if (!el) return;
if (!el)
return;
el.style.height = 'auto'; el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 520) + 'px'; el.style.height = Math.min(el.scrollHeight, 520) + 'px';
el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden'; el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden';
@@ -868,7 +963,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
requestAnimationFrame(() => { requestAnimationFrame(() => {
const bar = this.bottomBar?.nativeElement; const bar = this.bottomBar?.nativeElement;
const scroll = this.messagesContainer?.nativeElement; const scroll = this.messagesContainer?.nativeElement;
if (!bar || !scroll) return;
if (!bar || !scroll)
return;
scroll.style.paddingBottom = bar.offsetHeight + 20 + 'px'; scroll.style.paddingBottom = bar.offsetHeight + 20 + 'px';
}); });
} }
@@ -895,6 +993,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Track mouse leave on the toolbar; hide if input is not focused. */ /** Track mouse leave on the toolbar; hide if input is not focused. */
onToolbarMouseLeave(): void { onToolbarMouseLeave(): void {
this.toolbarHovering = false; this.toolbarHovering = false;
if (document.activeElement !== this.messageInputRef?.nativeElement) { if (document.activeElement !== this.messageInputRef?.nativeElement) {
this.toolbarVisible.set(false); this.toolbarVisible.set(false);
} }
@@ -902,12 +1001,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Handle Ctrl key down for enabling manual resize. */ /** Handle Ctrl key down for enabling manual resize. */
onDocKeydown(event: KeyboardEvent): void { onDocKeydown(event: KeyboardEvent): void {
if (event.key === 'Control') this.ctrlHeld.set(true); if (event.key === 'Control')
this.ctrlHeld.set(true);
} }
/** Handle Ctrl key up for disabling manual resize. */ /** Handle Ctrl key up for disabling manual resize. */
onDocKeyup(event: KeyboardEvent): void { onDocKeyup(event: KeyboardEvent): void {
if (event.key === 'Control') this.ctrlHeld.set(false); if (event.key === 'Control')
this.ctrlHeld.set(false);
} }
/** Scroll to the newest message and dismiss the new-messages snackbar. */ /** Scroll to the newest message and dismiss the new-messages snackbar. */

View File

@@ -20,6 +20,7 @@ export class ChatMarkdownService {
const after = content.slice(end); const after = content.slice(end);
const newText = `${before}${token}${selected}${token}${after}`; const newText = `${before}${token}${selected}${token}${after}`;
const cursor = before.length + token.length + selected.length + token.length; const cursor = before.length + token.length + selected.length + token.length;
return { text: newText, selectionStart: cursor, selectionEnd: cursor }; return { text: newText, selectionStart: cursor, selectionEnd: cursor };
} }
@@ -32,6 +33,7 @@ export class ChatMarkdownService {
const newSelected = lines.join('\n'); const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`; const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length; const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text, selectionStart: cursor, selectionEnd: cursor };
} }
@@ -46,6 +48,7 @@ export class ChatMarkdownService {
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`; const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const text = `${before}${block}${after}`; const text = `${before}${block}${after}`;
const cursor = before.length + block.length; const cursor = before.length + block.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text, selectionStart: cursor, selectionEnd: cursor };
} }
@@ -58,6 +61,7 @@ export class ChatMarkdownService {
const newSelected = lines.join('\n'); const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`; const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length; const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text, selectionStart: cursor, selectionEnd: cursor };
} }
@@ -69,6 +73,7 @@ export class ChatMarkdownService {
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`; const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`; const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length; const cursor = before.length + fenced.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text, selectionStart: cursor, selectionEnd: cursor };
} }
@@ -80,6 +85,7 @@ export class ChatMarkdownService {
const link = `[${selected}](https://)`; const link = `[${selected}](https://)`;
const text = `${before}${link}${after}`; const text = `${before}${link}${after}`;
const cursorStart = before.length + link.length - 1; const cursorStart = before.length + link.length - 1;
// Position inside the URL placeholder // Position inside the URL placeholder
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 }; return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
} }
@@ -92,6 +98,7 @@ export class ChatMarkdownService {
const img = `![${selected}](https://)`; const img = `![${selected}](https://)`;
const text = `${before}${img}${after}`; const text = `${before}${img}${after}`;
const cursorStart = before.length + img.length - 1; const cursorStart = before.length + img.length - 1;
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 }; return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
} }
@@ -99,26 +106,33 @@ export class ChatMarkdownService {
const { start, end } = selection; const { start, end } = selection;
const before = content.slice(0, start); const before = content.slice(0, start);
const after = content.slice(end); const after = content.slice(end);
const hr = `\n\n---\n\n`; const hr = '\n\n---\n\n';
const text = `${before}${hr}${after}`; const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length; const cursor = before.length + hr.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text, selectionStart: cursor, selectionEnd: cursor };
} }
appendImageMarkdown(content: string): string { appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig; const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>(); const urls = new Set<string>();
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
const text = content; const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) { while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]); urls.add(match[1]);
} }
if (urls.size === 0) return content; if (urls.size === 0)
return content;
let append = ''; let append = '';
for (const url of urls) { for (const url of urls) {
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text); const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) { if (!alreadyEmbedded) {
append += `\n![](${url})`; append += `\n![](${url})`;
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
import { Component, inject, signal, DestroyRef } from '@angular/core'; import { Component, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
@@ -13,8 +14,8 @@ const MAX_SHOWN = 4;
templateUrl: './typing-indicator.component.html', templateUrl: './typing-indicator.component.html',
host: { host: {
'class': 'block', 'class': 'block',
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));', 'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
}, }
}) })
export class TypingIndicatorComponent { export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>(); private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
@@ -25,30 +26,31 @@ export class TypingIndicatorComponent {
constructor() { constructor() {
const webrtc = inject(WebRTCService); const webrtc = inject(WebRTCService);
const destroyRef = inject(DestroyRef); const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe( const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId), filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
tap((msg: any) => { tap((msg: any) => {
const now = Date.now(); const now = Date.now();
this.typingMap.set(String(msg.oderId), { this.typingMap.set(String(msg.oderId), {
name: String(msg.displayName), name: String(msg.displayName),
expiresAt: now + TYPING_TTL, expiresAt: now + TYPING_TTL
}); });
}), })
); );
const purge$ = interval(PURGE_INTERVAL).pipe( const purge$ = interval(PURGE_INTERVAL).pipe(
map(() => Date.now()), map(() => Date.now()),
filter((now) => { filter((now) => {
let changed = false; let changed = false;
for (const [key, entry] of this.typingMap) { for (const [key, entry] of this.typingMap) {
if (entry.expiresAt <= now) { if (entry.expiresAt <= now) {
this.typingMap.delete(key); this.typingMap.delete(key);
changed = true; changed = true;
} }
} }
return changed; return changed;
}), })
); );
merge(typing$, purge$) merge(typing$, purge$)
@@ -61,6 +63,7 @@ export class TypingIndicatorComponent {
const names = Array.from(this.typingMap.values()) const names = Array.from(this.typingMap.values())
.filter((e) => e.expiresAt > now) .filter((e) => e.expiresAt > now)
.map((e) => e.name); .map((e) => e.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN)); this.typingDisplay.set(names.slice(0, MAX_SHOWN));
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN)); this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
} }

View File

@@ -20,6 +20,12 @@
<div <div
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer" class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
(click)="toggleUserMenu(user.id)" (click)="toggleUserMenu(user.id)"
(keydown.enter)="toggleUserMenu(user.id)"
(keydown.space)="toggleUserMenu(user.id)"
(keyup.enter)="toggleUserMenu(user.id)"
(keyup.space)="toggleUserMenu(user.id)"
role="button"
tabindex="0"
> >
<!-- Avatar with online indicator --> <!-- Avatar with online indicator -->
<div class="relative"> <div class="relative">
@@ -65,9 +71,13 @@
<div <div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1" class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="menu"
tabindex="0"
> >
@if (user.voiceState?.isConnected) { @if (user.voiceState?.isConnected) {
<button <button
type="button"
(click)="muteUser(user)" (click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2" class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
> >
@@ -81,6 +91,7 @@
</button> </button>
} }
<button <button
type="button"
(click)="kickUser(user)" (click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500" class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
> >
@@ -88,6 +99,7 @@
<span>Kick</span> <span>Kick</span>
</button> </button>
<button <button
type="button"
(click)="banUser(user)" (click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive" class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
> >
@@ -121,19 +133,21 @@
</p> </p>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label> <label for="ban-reason-input" class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
<input <input
type="text" type="text"
[(ngModel)]="banReason" [(ngModel)]="banReason"
placeholder="Enter ban reason..." placeholder="Enter ban reason..."
id="ban-reason-input"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-foreground mb-1">Duration</label> <label for="ban-duration-select" class="block text-sm font-medium text-foreground mb-1">Duration</label>
<select <select
[(ngModel)]="banDuration" [(ngModel)]="banDuration"
id="ban-duration-select"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option value="3600000">1 hour</option> <option value="3600000">1 hour</option>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal, computed } from '@angular/core'; import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -13,14 +14,14 @@ import {
lucideBan, lucideBan,
lucideUserX, lucideUserX,
lucideVolume2, lucideVolume2,
lucideVolumeX, lucideVolumeX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { import {
selectOnlineUsers, selectOnlineUsers,
selectCurrentUser, selectCurrentUser,
selectIsCurrentUserAdmin, selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { User } from '../../../core/models'; import { User } from '../../../core/models';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
@@ -40,10 +41,10 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
lucideBan, lucideBan,
lucideUserX, lucideUserX,
lucideVolume2, lucideVolume2,
lucideVolumeX, lucideVolumeX
}), })
], ],
templateUrl: './user-list.component.html', templateUrl: './user-list.component.html'
}) })
/** /**
* Displays the list of online users with voice state indicators and admin actions. * Displays the list of online users with voice state indicators and admin actions.
@@ -79,6 +80,7 @@ export class UserListComponent {
} else { } else {
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id })); this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
} }
this.showUserMenu.set(null); this.showUserMenu.set(null);
} }
@@ -106,7 +108,9 @@ export class UserListComponent {
/** Confirm the ban, dispatch the action with duration, and close the dialog. */ /** Confirm the ban, dispatch the action with duration, and close the dialog. */
confirmBan(): void { confirmBan(): void {
const user = this.userToBan(); const user = this.userToBan();
if (!user) return;
if (!user)
return;
const duration = parseInt(this.banDuration, 10); const duration = parseInt(this.banDuration, 10);
const expiresAt = duration === 0 ? undefined : Date.now() + duration; const expiresAt = duration === 0 ? undefined : Date.now() + duration;
@@ -115,7 +119,7 @@ export class UserListComponent {
UsersActions.banUser({ UsersActions.banUser({
userId: user.id, userId: user.id,
reason: this.banReason || undefined, reason: this.banReason || undefined,
expiresAt, expiresAt
}) })
); );

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -9,7 +10,7 @@ import {
lucideUsers, lucideUsers,
lucideMenu, lucideMenu,
lucideX, lucideX,
lucideChevronLeft, lucideChevronLeft
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component'; import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
@@ -19,13 +20,11 @@ import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.co
import { import {
selectCurrentRoom, selectCurrentRoom,
selectActiveChannelId, selectActiveChannelId,
selectTextChannels, selectTextChannels
} from '../../../store/rooms/rooms.selectors'; } from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
@Component({ @Component({
selector: 'app-chat-room', selector: 'app-chat-room',
standalone: true, standalone: true,
@@ -34,7 +33,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
NgIcon, NgIcon,
ChatMessagesComponent, ChatMessagesComponent,
ScreenShareViewerComponent, ScreenShareViewerComponent,
RoomsSidePanelComponent, RoomsSidePanelComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -43,10 +42,10 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
lucideUsers, lucideUsers,
lucideMenu, lucideMenu,
lucideX, lucideX,
lucideChevronLeft, lucideChevronLeft
}), })
], ],
templateUrl: './chat-room.component.html', templateUrl: './chat-room.component.html'
}) })
/** /**
* Main chat room view combining the messages panel, side panels, and admin controls. * Main chat room view combining the messages panel, side panels, and admin controls.
@@ -67,12 +66,14 @@ export class ChatRoomComponent {
get activeChannelName(): string { get activeChannelName(): string {
const id = this.activeChannelId(); const id = this.activeChannelId();
const activeChannel = this.textChannels().find((channel) => channel.id === id); const activeChannel = this.textChannels().find((channel) => channel.id === id);
return activeChannel ? activeChannel.name : id; return activeChannel ? activeChannel.name : id;
} }
/** Open the settings modal to the Server admin page for the current room. */ /** Open the settings modal to the Server admin page for the current room. */
toggleAdminPanel() { toggleAdminPanel() {
const room = this.currentRoom(); const room = this.currentRoom();
if (room) { if (room) {
this.settingsModal.open('server', room.id); this.settingsModal.open('server', room.id);
} }

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
<aside class="w-80 bg-card h-full flex flex-col"> <aside class="w-80 bg-card h-full flex flex-col">
<!-- Minimalistic header with tabs --> <!-- Minimalistic header with tabs -->
<div class="border-b border-border"> <div class="border-b border-border">

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -11,18 +12,18 @@ import {
lucideMonitor, lucideMonitor,
lucideHash, lucideHash,
lucideUsers, lucideUsers,
lucidePlus, lucidePlus
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { import {
selectOnlineUsers, selectOnlineUsers,
selectCurrentUser, selectCurrentUser,
selectIsCurrentUserAdmin, selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { import {
selectCurrentRoom, selectCurrentRoom,
selectActiveChannelId, selectActiveChannelId,
selectTextChannels, selectTextChannels,
selectVoiceChannels, selectVoiceChannels
} from '../../../store/rooms/rooms.selectors'; } from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../store/rooms/rooms.actions';
@@ -47,7 +48,7 @@ type TabView = 'channels' | 'users';
VoiceControlsComponent, VoiceControlsComponent,
ContextMenuComponent, ContextMenuComponent,
UserAvatarComponent, UserAvatarComponent,
ConfirmDialogComponent, ConfirmDialogComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -58,10 +59,10 @@ type TabView = 'channels' | 'users';
lucideMonitor, lucideMonitor,
lucideHash, lucideHash,
lucideUsers, lucideUsers,
lucidePlus, lucidePlus
}), })
], ],
templateUrl: './rooms-side-panel.component.html', templateUrl: './rooms-side-panel.component.html'
}) })
/** /**
* Side panel listing text and voice channels, online users, and channel management actions. * Side panel listing text and voice channels, online users, and channel management actions.
@@ -108,8 +109,9 @@ export class RoomsSidePanelComponent {
const current = this.currentUser(); const current = this.currentUser();
const currentId = current?.id; const currentId = current?.id;
const currentOderId = current?.oderId; const currentOderId = current?.oderId;
return this.onlineUsers().filter( return this.onlineUsers().filter(
(user) => user.id !== currentId && user.oderId !== currentOderId, (user) => user.id !== currentId && user.oderId !== currentOderId
); );
} }
@@ -117,19 +119,31 @@ export class RoomsSidePanelComponent {
canManageChannels(): boolean { canManageChannels(): boolean {
const room = this.currentRoom(); const room = this.currentRoom();
const user = this.currentUser(); const user = this.currentUser();
if (!room || !user) return false;
if (!room || !user)
return false;
// Owner always can // Owner always can
if (room.hostId === user.id) return true; if (room.hostId === user.id)
return true;
const perms = room.permissions || {}; const perms = room.permissions || {};
if (user.role === 'admin' && perms.adminsManageRooms) return true;
if (user.role === 'moderator' && perms.moderatorsManageRooms) return true; if (user.role === 'admin' && perms.adminsManageRooms)
return true;
if (user.role === 'moderator' && perms.moderatorsManageRooms)
return true;
return false; return false;
} }
/** Select a text channel (no-op if currently renaming). */ /** Select a text channel (no-op if currently renaming). */
// ---- Text channel selection ---- // ---- Text channel selection ----
selectTextChannel(channelId: string) { selectTextChannel(channelId: string) {
if (this.renamingChannelId()) return; // don't switch while renaming if (this.renamingChannelId())
return; // don't switch while renaming
this.store.dispatch(RoomsActions.selectChannel({ channelId })); this.store.dispatch(RoomsActions.selectChannel({ channelId }));
} }
@@ -151,7 +165,9 @@ export class RoomsSidePanelComponent {
/** Begin inline renaming of the context-menu channel. */ /** Begin inline renaming of the context-menu channel. */
startRename() { startRename() {
const ch = this.contextChannel(); const ch = this.contextChannel();
this.closeChannelMenu(); this.closeChannelMenu();
if (ch) { if (ch) {
this.renamingChannelId.set(ch.id); this.renamingChannelId.set(ch.id);
} }
@@ -162,9 +178,11 @@ export class RoomsSidePanelComponent {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const name = input.value.trim(); const name = input.value.trim();
const channelId = this.renamingChannelId(); const channelId = this.renamingChannelId();
if (channelId && name) { if (channelId && name) {
this.store.dispatch(RoomsActions.renameChannel({ channelId, name })); this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
} }
this.renamingChannelId.set(null); this.renamingChannelId.set(null);
} }
@@ -176,7 +194,9 @@ export class RoomsSidePanelComponent {
/** Delete the context-menu channel. */ /** Delete the context-menu channel. */
deleteChannel() { deleteChannel() {
const ch = this.contextChannel(); const ch = this.contextChannel();
this.closeChannelMenu(); this.closeChannelMenu();
if (ch) { if (ch) {
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id })); this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
} }
@@ -186,6 +206,7 @@ export class RoomsSidePanelComponent {
resyncMessages() { resyncMessages() {
this.closeChannelMenu(); this.closeChannelMenu();
const room = this.currentRoom(); const room = this.currentRoom();
if (!room) { if (!room) {
return; return;
} }
@@ -195,9 +216,11 @@ export class RoomsSidePanelComponent {
// Request inventory from all connected peers // Request inventory from all connected peers
const peers = this.webrtc.getConnectedPeers(); const peers = this.webrtc.getConnectedPeers();
if (peers.length === 0) { if (peers.length === 0) {
// No connected peers — sync will time out // No connected peers — sync will time out
} }
peers.forEach((pid) => { peers.forEach((pid) => {
try { try {
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any); this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
@@ -218,15 +241,19 @@ export class RoomsSidePanelComponent {
/** Confirm channel creation and dispatch the add-channel action. */ /** Confirm channel creation and dispatch the add-channel action. */
confirmCreateChannel() { confirmCreateChannel() {
const name = this.newChannelName.trim(); const name = this.newChannelName.trim();
if (!name) return;
if (!name)
return;
const type = this.createChannelType(); const type = this.createChannelType();
const existing = type === 'text' ? this.textChannels() : this.voiceChannels(); const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
const channel: Channel = { const channel: Channel = {
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8), id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
name, name,
type, type,
position: existing.length, position: existing.length
}; };
this.store.dispatch(RoomsActions.addChannel({ channel })); this.store.dispatch(RoomsActions.addChannel({ channel }));
this.showCreateChannelDialog.set(false); this.showCreateChannelDialog.set(false);
} }
@@ -240,7 +267,10 @@ export class RoomsSidePanelComponent {
// ---- User context menu (kick/role) ---- // ---- User context menu (kick/role) ----
openUserContextMenu(evt: MouseEvent, user: User) { openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault(); evt.preventDefault();
if (!this.isAdmin()) return;
if (!this.isAdmin())
return;
this.contextMenuUser.set(user); this.contextMenuUser.set(user);
this.userMenuX.set(evt.clientX); this.userMenuX.set(evt.clientX);
this.userMenuY.set(evt.clientY); this.userMenuY.set(evt.clientY);
@@ -255,14 +285,16 @@ export class RoomsSidePanelComponent {
/** Change a user's role and broadcast the update to connected peers. */ /** Change a user's role and broadcast the update to connected peers. */
changeUserRole(role: 'admin' | 'moderator' | 'member') { changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser(); const user = this.contextMenuUser();
this.closeUserMenu(); this.closeUserMenu();
if (user) { if (user) {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
// Broadcast role change to peers // Broadcast role change to peers
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'role-change', type: 'role-change',
targetUserId: user.id, targetUserId: user.id,
role, role
}); });
} }
} }
@@ -270,14 +302,16 @@ export class RoomsSidePanelComponent {
/** Kick a user and broadcast the action to peers. */ /** Kick a user and broadcast the action to peers. */
kickUserAction() { kickUserAction() {
const user = this.contextMenuUser(); const user = this.contextMenuUser();
this.closeUserMenu(); this.closeUserMenu();
if (user) { if (user) {
this.store.dispatch(UsersActions.kickUser({ userId: user.id })); this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
// Broadcast kick to peers // Broadcast kick to peers
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'kick', type: 'kick',
targetUserId: user.id, targetUserId: user.id,
kickedBy: this.currentUser()?.id, kickedBy: this.currentUser()?.id
}); });
} }
} }
@@ -287,6 +321,7 @@ export class RoomsSidePanelComponent {
joinVoice(roomId: string) { joinVoice(roomId: string) {
// Gate by room permissions // Gate by room permissions
const room = this.currentRoom(); const room = this.currentRoom();
if (room && room.permissions && room.permissions.allowVoice === false) { if (room && room.permissions && room.permissions.allowVoice === false) {
// Voice is disabled by room permissions // Voice is disabled by room permissions
return; return;
@@ -309,9 +344,9 @@ export class RoomsSidePanelComponent {
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
roomId: undefined, roomId: undefined,
serverId: undefined, serverId: undefined
}, }
}), })
); );
} }
} else { } else {
@@ -325,7 +360,6 @@ export class RoomsSidePanelComponent {
current?.voiceState?.isConnected && current?.voiceState?.isConnected &&
current.voiceState.serverId === room?.id && current.voiceState.serverId === room?.id &&
current.voiceState.roomId !== roomId; current.voiceState.roomId !== roomId;
// Enable microphone and broadcast voice-state // Enable microphone and broadcast voice-state
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
@@ -340,11 +374,12 @@ export class RoomsSidePanelComponent {
isMuted: current.voiceState?.isMuted ?? false, isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false, isDeafened: current.voiceState?.isDeafened ?? false,
roomId: roomId, roomId: roomId,
serverId: room.id, serverId: room.id
}, }
}), })
); );
} }
// Start voice heartbeat to broadcast presence every 5 seconds // Start voice heartbeat to broadcast presence every 5 seconds
this.webrtc.startVoiceHeartbeat(roomId, room?.id); this.webrtc.startVoiceHeartbeat(roomId, room?.id);
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
@@ -356,8 +391,8 @@ export class RoomsSidePanelComponent {
isMuted: current?.voiceState?.isMuted ?? false, isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false, isDeafened: current?.voiceState?.isDeafened ?? false,
roomId: roomId, roomId: roomId,
serverId: room?.id, serverId: room?.id
}, }
}); });
// Update voice session for floating controls // Update voice session for floating controls
@@ -365,6 +400,7 @@ export class RoomsSidePanelComponent {
// Find label from channel list // Find label from channel list
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId); const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId; const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({ this.voiceSessionService.startSession({
serverId: room.id, serverId: room.id,
serverName: room.name, serverName: room.name,
@@ -372,7 +408,7 @@ export class RoomsSidePanelComponent {
roomName: voiceRoomName, roomName: voiceRoomName,
serverIcon: room.icon, serverIcon: room.icon,
serverDescription: room.description, serverDescription: room.description,
serverRoute: `/room/${room.id}`, serverRoute: `/room/${room.id}`
}); });
} }
}) })
@@ -384,8 +420,10 @@ export class RoomsSidePanelComponent {
/** Leave a voice channel and broadcast the disconnect state. */ /** Leave a voice channel and broadcast the disconnect state. */
leaveVoice(roomId: string) { leaveVoice(roomId: string) {
const current = this.currentUser(); const current = this.currentUser();
// Only leave if currently in this room // Only leave if currently in this room
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return; if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
return;
// Stop voice heartbeat // Stop voice heartbeat
this.webrtc.stopVoiceHeartbeat(); this.webrtc.stopVoiceHeartbeat();
@@ -403,9 +441,9 @@ export class RoomsSidePanelComponent {
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
roomId: undefined, roomId: undefined,
serverId: undefined, serverId: undefined
}, }
}), })
); );
} }
@@ -419,8 +457,8 @@ export class RoomsSidePanelComponent {
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
roomId: undefined, roomId: undefined,
serverId: undefined, serverId: undefined
}, }
}); });
// End voice session // End voice session
@@ -431,50 +469,59 @@ export class RoomsSidePanelComponent {
voiceOccupancy(roomId: string): number { voiceOccupancy(roomId: string): number {
const users = this.onlineUsers(); const users = this.onlineUsers();
const room = this.currentRoom(); const room = this.currentRoom();
return users.filter( return users.filter(
(user) => (user) =>
!!user.voiceState?.isConnected && !!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId && user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id, user.voiceState?.serverId === room?.id
).length; ).length;
} }
/** Dispatch a viewer:focus event to display a remote user's screen share. */ /** Dispatch a viewer:focus event to display a remote user's screen share. */
viewShare(userId: string) { viewShare(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } }); const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt); window.dispatchEvent(evt);
} }
/** Dispatch a viewer:focus event to display a remote user's stream. */ /** Dispatch a viewer:focus event to display a remote user's stream. */
viewStream(userId: string) { viewStream(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } }); const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt); window.dispatchEvent(evt);
} }
/** Check whether a user is currently sharing their screen. */ /** Check whether a user is currently sharing their screen. */
isUserSharing(userId: string): boolean { isUserSharing(userId: string): boolean {
const me = this.currentUser(); const me = this.currentUser();
if (me?.id === userId) { if (me?.id === userId) {
return this.webrtc.isScreenSharing(); return this.webrtc.isScreenSharing();
} }
const user = this.onlineUsers().find( const user = this.onlineUsers().find(
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId, (onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
); );
if (user?.screenShareState?.isSharing === false) { if (user?.screenShareState?.isSharing === false) {
return false; return false;
} }
const stream = this.webrtc.getRemoteStream(userId); const stream = this.webrtc.getRemoteStream(userId);
return !!stream && stream.getVideoTracks().length > 0; return !!stream && stream.getVideoTracks().length > 0;
} }
/** Return all users currently connected to a specific voice channel. */ /** Return all users currently connected to a specific voice channel. */
voiceUsersInRoom(roomId: string) { voiceUsersInRoom(roomId: string) {
const room = this.currentRoom(); const room = this.currentRoom();
return this.onlineUsers().filter( return this.onlineUsers().filter(
(user) => (user) =>
!!user.voiceState?.isConnected && !!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId && user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id, user.voiceState?.serverId === room?.id
); );
} }
@@ -482,6 +529,7 @@ export class RoomsSidePanelComponent {
isCurrentRoom(roomId: string): boolean { isCurrentRoom(roomId: string): boolean {
const me = this.currentUser(); const me = this.currentUser();
const room = this.currentRoom(); const room = this.currentRoom();
return !!( return !!(
me?.voiceState?.isConnected && me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId && me.voiceState?.roomId === roomId &&
@@ -492,6 +540,7 @@ export class RoomsSidePanelComponent {
/** Check whether voice is enabled by the current room's permissions. */ /** Check whether voice is enabled by the current room's permissions. */
voiceEnabled(): boolean { voiceEnabled(): boolean {
const room = this.currentRoom(); const room = this.currentRoom();
return room?.permissions?.allowVoice !== false; return room?.permissions?.allowVoice !== false;
} }
@@ -501,6 +550,7 @@ export class RoomsSidePanelComponent {
*/ */
getPeerLatency(user: User): number | null { getPeerLatency(user: User): number | null {
const latencies = this.webrtc.peerLatencies(); const latencies = this.webrtc.peerLatencies();
// Try oderId first (primary peer key), then fall back to user id // Try oderId first (primary peer key), then fall back to user id
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null; return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
} }
@@ -515,10 +565,19 @@ export class RoomsSidePanelComponent {
*/ */
getPingColorClass(user: User): string { getPingColorClass(user: User): string {
const ms = this.getPeerLatency(user); const ms = this.getPeerLatency(user);
if (ms === null) return 'bg-gray-500';
if (ms < 100) return 'bg-green-500'; if (ms === null)
if (ms < 200) return 'bg-yellow-500'; return 'bg-gray-500';
if (ms < 350) return 'bg-orange-500';
if (ms < 100)
return 'bg-green-500';
if (ms < 200)
return 'bg-yellow-500';
if (ms < 350)
return 'bg-orange-500';
return 'bg-red-500'; return 'bg-red-500';
} }
} }

View File

@@ -9,6 +9,7 @@
@for (room of savedRooms(); track room.id) { @for (room of savedRooms(); track room.id) {
<button <button
(click)="joinSavedRoom(room)" (click)="joinSavedRoom(room)"
type="button"
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground" class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
> >
{{ room.name }} {{ room.name }}
@@ -35,6 +36,7 @@
</div> </div>
<button <button
(click)="openSettings()" (click)="openSettings()"
type="button"
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors" class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
title="Settings" title="Settings"
> >
@@ -47,6 +49,7 @@
<div class="p-4 border-b border-border"> <div class="p-4 border-b border-border">
<button <button
(click)="openCreateDialog()" (click)="openCreateDialog()"
type="button"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors" class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
> >
<ng-icon name="lucidePlus" class="w-4 h-4" /> <ng-icon name="lucidePlus" class="w-4 h-4" />
@@ -71,6 +74,7 @@
@for (server of searchResults(); track server.id) { @for (server of searchResults(); track server.id) {
<button <button
(click)="joinServer(server)" (click)="joinServer(server)"
type="button"
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group" class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
@@ -119,37 +123,56 @@
<!-- Create Server Dialog --> <!-- Create Server Dialog -->
@if (showCreateDialog()) { @if (showCreateDialog()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeCreateDialog()"> <div
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()"> class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
(click)="closeCreateDialog()"
(keydown.enter)="closeCreateDialog()"
(keydown.space)="closeCreateDialog()"
role="button"
tabindex="0"
aria-label="Close create server dialog"
>
<div
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2> <h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-foreground mb-1">Server Name</label> <label for="create-server-name" class="block text-sm font-medium text-foreground mb-1">Server Name</label>
<input <input
type="text" type="text"
[(ngModel)]="newServerName" [(ngModel)]="newServerName"
placeholder="My Awesome Server" placeholder="My Awesome Server"
id="create-server-name"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-foreground mb-1">Description (optional)</label> <label for="create-server-description" class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
<textarea <textarea
[(ngModel)]="newServerDescription" [(ngModel)]="newServerDescription"
placeholder="What's your server about?" placeholder="What's your server about?"
rows="3" rows="3"
id="create-server-description"
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 resize-none" 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 resize-none"
></textarea> ></textarea>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label> <label for="create-server-topic" class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
<input <input
type="text" type="text"
[(ngModel)]="newServerTopic" [(ngModel)]="newServerTopic"
placeholder="gaming, music, coding..." placeholder="gaming, music, coding..."
id="create-server-topic"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</div> </div>
@@ -166,11 +189,12 @@
@if (newServerPrivate()) { @if (newServerPrivate()) {
<div> <div>
<label class="block text-sm font-medium text-foreground mb-1">Password</label> <label for="create-server-password" class="block text-sm font-medium text-foreground mb-1">Password</label>
<input <input
type="password" type="password"
[(ngModel)]="newServerPassword" [(ngModel)]="newServerPassword"
placeholder="Enter password" 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" 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>
@@ -180,6 +204,7 @@
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button <button
(click)="closeCreateDialog()" (click)="closeCreateDialog()"
type="button"
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors" class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
> >
Cancel Cancel
@@ -187,6 +212,7 @@
<button <button
(click)="createServer()" (click)="createServer()"
[disabled]="!newServerName()" [disabled]="!newServerName()"
type="button"
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Create Create

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, OnInit, signal } from '@angular/core'; import { Component, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -11,7 +12,7 @@ import {
lucideLock, lucideLock,
lucideGlobe, lucideGlobe,
lucidePlus, lucidePlus,
lucideSettings, lucideSettings
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
@@ -19,7 +20,7 @@ import {
selectSearchResults, selectSearchResults,
selectIsSearching, selectIsSearching,
selectRoomsError, selectRoomsError,
selectSavedRooms, selectSavedRooms
} from '../../store/rooms/rooms.selectors'; } from '../../store/rooms/rooms.selectors';
import { Room } from '../../core/models'; import { Room } from '../../core/models';
import { ServerInfo } from '../../core/models'; import { ServerInfo } from '../../core/models';
@@ -36,10 +37,10 @@ import { SettingsModalService } from '../../core/services/settings-modal.service
lucideLock, lucideLock,
lucideGlobe, lucideGlobe,
lucidePlus, lucidePlus,
lucideSettings, lucideSettings
}), })
], ],
templateUrl: './server-search.component.html', templateUrl: './server-search.component.html'
}) })
/** /**
* Server search and discovery view with server creation dialog. * Server search and discovery view with server creation dialog.
@@ -85,19 +86,21 @@ export class ServerSearchComponent implements OnInit {
/** Join a server from the search results. Redirects to login if unauthenticated. */ /** Join a server from the search results. Redirects to login if unauthenticated. */
joinServer(server: ServerInfo): void { joinServer(server: ServerInfo): void {
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login']);
return; return;
} }
this.store.dispatch( this.store.dispatch(
RoomsActions.joinRoom({ RoomsActions.joinRoom({
roomId: server.id, roomId: server.id,
serverInfo: { serverInfo: {
name: server.name, name: server.name,
description: server.description, description: server.description,
hostName: server.hostName, hostName: server.hostName
}, }
}), })
); );
} }
@@ -114,8 +117,11 @@ export class ServerSearchComponent implements OnInit {
/** Submit the new server creation form and dispatch the create action. */ /** Submit the new server creation form and dispatch the create action. */
createServer(): void { createServer(): void {
if (!this.newServerName()) return; if (!this.newServerName())
return;
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login']);
return; return;
@@ -127,8 +133,8 @@ export class ServerSearchComponent implements OnInit {
description: this.newServerDescription() || undefined, description: this.newServerDescription() || undefined,
topic: this.newServerTopic() || undefined, topic: this.newServerTopic() || undefined,
isPrivate: this.newServerPrivate(), isPrivate: this.newServerPrivate(),
password: this.newServerPrivate() ? this.newServerPassword() : undefined, password: this.newServerPrivate() ? this.newServerPassword() : undefined
}), })
); );
this.closeCreateDialog(); this.closeCreateDialog();
@@ -149,7 +155,7 @@ export class ServerSearchComponent implements OnInit {
userCount: room.userCount, userCount: room.userCount,
maxUsers: room.maxUsers || 50, maxUsers: room.maxUsers || 50,
isPrivate: !!room.password, isPrivate: !!room.password,
createdAt: room.createdAt, createdAt: room.createdAt
} as any); } as any);
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -17,7 +18,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent], imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
viewProviders: [provideIcons({ lucidePlus })], viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html', templateUrl: './servers-rail.component.html'
}) })
/** /**
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting. * Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
@@ -40,8 +41,11 @@ export class ServersRailComponent {
/** Return the first character of a server name as its icon initial. */ /** Return the first character of a server name as its icon initial. */
initial(name?: string): string { initial(name?: string): string {
if (!name) return '?'; if (!name)
return '?';
const ch = name.trim()[0]?.toUpperCase(); const ch = name.trim()[0]?.toUpperCase();
return ch || '?'; return ch || '?';
} }
@@ -52,9 +56,11 @@ export class ServersRailComponent {
// Navigate to server list (has create button) // Navigate to server list (has create button)
// Update voice session state if connected to voice // Update voice session state if connected to voice
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId) { if (voiceServerId) {
this.voiceSession.setViewingVoiceServer(false); this.voiceSession.setViewingVoiceServer(false);
} }
this.router.navigate(['/search']); this.router.navigate(['/search']);
} }
@@ -62,6 +68,7 @@ export class ServersRailComponent {
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
// Require auth: if no current user, go to login // Require auth: if no current user, go to login
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login']);
return; return;
@@ -69,6 +76,7 @@ export class ServersRailComponent {
// Check if we're navigating to a different server while in voice // Check if we're navigating to a different server while in voice
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) { if (voiceServerId && voiceServerId !== room.id) {
// User is switching to a different server while connected to voice // User is switching to a different server while connected to voice
// Update voice session to show floating controls (voice stays connected) // Update voice session to show floating controls (voice stays connected)
@@ -89,8 +97,8 @@ export class ServersRailComponent {
serverInfo: { serverInfo: {
name: room.name, name: room.name,
description: room.description, description: room.description,
hostName: room.hostId || 'Unknown', hostName: room.hostId || 'Unknown'
}, }
})); }));
} }
} }
@@ -115,6 +123,7 @@ export class ServersRailComponent {
isCurrentContextRoom(): boolean { isCurrentContextRoom(): boolean {
const ctx = this.contextRoom(); const ctx = this.contextRoom();
const cur = this.currentRoom(); const cur = this.currentRoom();
return !!ctx && !!cur && ctx.id === cur.id; return !!ctx && !!cur && ctx.id === cur.id;
} }
@@ -134,11 +143,15 @@ export class ServersRailComponent {
/** Forget (remove) a server from the saved list, leaving if it is the current room. */ /** Forget (remove) a server from the saved list, leaving if it is the current room. */
confirmForget(): void { confirmForget(): void {
const ctx = this.contextRoom(); const ctx = this.contextRoom();
if (!ctx) return;
if (!ctx)
return;
if (this.currentRoom()?.id === ctx.id) { if (this.currentRoom()?.id === ctx.id) {
this.store.dispatch(RoomsActions.leaveRoom()); this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers')); window.dispatchEvent(new CustomEvent('navigate:servers'));
} }
this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id })); this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id }));
this.showConfirm.set(false); this.showConfirm.set(false);
this.contextRoom.set(null); this.contextRoom.set(null);

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input } from '@angular/core'; import { Component, inject, input } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -14,10 +15,10 @@ import { selectBannedUsers } from '../../../../store/users/users.selectors';
imports: [CommonModule, NgIcon], imports: [CommonModule, NgIcon],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideX, lucideX
}), })
], ],
templateUrl: './bans-settings.component.html', templateUrl: './bans-settings.component.html'
}) })
export class BansSettingsComponent { export class BansSettingsComponent {
private store = inject(Store); private store = inject(Store);
@@ -35,6 +36,7 @@ export class BansSettingsComponent {
formatExpiry(timestamp: number): string { formatExpiry(timestamp: number): string {
const date = new Date(timestamp); const date = new Date(timestamp);
return ( return (
date.toLocaleDateString() + date.toLocaleDateString() +
' ' + ' ' +

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input } from '@angular/core'; import { Component, inject, input } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -18,10 +19,10 @@ import { UserAvatarComponent } from '../../../../shared';
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideUserX, lucideUserX,
lucideBan, lucideBan
}), })
], ],
templateUrl: './members-settings.component.html', templateUrl: './members-settings.component.html'
}) })
export class MembersSettingsComponent { export class MembersSettingsComponent {
private store = inject(Store); private store = inject(Store);
@@ -37,6 +38,7 @@ export class MembersSettingsComponent {
membersFiltered(): User[] { membersFiltered(): User[] {
const me = this.currentUser(); const me = this.currentUser();
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId); return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
} }
@@ -45,7 +47,7 @@ export class MembersSettingsComponent {
this.webrtcService.broadcastMessage({ this.webrtcService.broadcastMessage({
type: 'role-change', type: 'role-change',
targetUserId: user.id, targetUserId: user.id,
role, role
}); });
} }
@@ -54,7 +56,7 @@ export class MembersSettingsComponent {
this.webrtcService.broadcastMessage({ this.webrtcService.broadcastMessage({
type: 'kick', type: 'kick',
targetUserId: user.id, targetUserId: user.id,
kickedBy: this.currentUser()?.id, kickedBy: this.currentUser()?.id
}); });
} }
@@ -63,7 +65,7 @@ export class MembersSettingsComponent {
this.webrtcService.broadcastMessage({ this.webrtcService.broadcastMessage({
type: 'ban', type: 'ban',
targetUserId: user.id, targetUserId: user.id,
bannedBy: this.currentUser()?.id, bannedBy: this.currentUser()?.id
}); });
} }
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -8,7 +9,7 @@ import {
lucideRefreshCw, lucideRefreshCw,
lucidePlus, lucidePlus,
lucideTrash2, lucideTrash2,
lucideCheck, lucideCheck
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../../../core/services/server-directory.service'; import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
@@ -25,10 +26,10 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
lucideRefreshCw, lucideRefreshCw,
lucidePlus, lucidePlus,
lucideTrash2, lucideTrash2,
lucideCheck, lucideCheck
}), })
], ],
templateUrl: './network-settings.component.html', templateUrl: './network-settings.component.html'
}) })
export class NetworkSettingsComponent { export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryService); private serverDirectory = inject(ServerDirectoryService);
@@ -47,25 +48,30 @@ export class NetworkSettingsComponent {
addServer(): void { addServer(): void {
this.addError.set(null); this.addError.set(null);
try { try {
new URL(this.newServerUrl); new URL(this.newServerUrl);
} catch { } catch {
this.addError.set('Please enter a valid URL'); this.addError.set('Please enter a valid URL');
return; return;
} }
if (this.servers().some((s) => s.url === this.newServerUrl)) {
if (this.servers().some((serverEntry) => serverEntry.url === this.newServerUrl)) {
this.addError.set('This server URL already exists'); this.addError.set('This server URL already exists');
return; return;
} }
this.serverDirectory.addServer({ this.serverDirectory.addServer({
name: this.newServerName.trim(), name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, ''), url: this.newServerUrl.trim().replace(/\/$/, '')
}); });
this.newServerName = ''; this.newServerName = '';
this.newServerUrl = ''; this.newServerUrl = '';
const servers = this.servers(); const servers = this.servers();
const newServer = servers[servers.length - 1]; const newServer = servers[servers.length - 1];
if (newServer) this.serverDirectory.testServer(newServer.id);
if (newServer)
this.serverDirectory.testServer(newServer.id);
} }
removeServer(id: string): void { removeServer(id: string): void {
@@ -84,8 +90,10 @@ export class NetworkSettingsComponent {
loadConnectionSettings(): void { loadConnectionSettings(): void {
const raw = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS); const raw = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
this.autoReconnect = parsed.autoReconnect ?? true; this.autoReconnect = parsed.autoReconnect ?? true;
this.searchAllServers = parsed.searchAllServers ?? true; this.searchAllServers = parsed.searchAllServers ?? true;
this.serverDirectory.setSearchAllServers(this.searchAllServers); this.serverDirectory.setSearchAllServers(this.searchAllServers);
@@ -97,8 +105,8 @@ export class NetworkSettingsComponent {
STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_CONNECTION_SETTINGS,
JSON.stringify({ JSON.stringify({
autoReconnect: this.autoReconnect, autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers, searchAllServers: this.searchAllServers
}), })
); );
this.serverDirectory.setSearchAllServers(this.searchAllServers); this.serverDirectory.setSearchAllServers(this.searchAllServers);
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input, signal } from '@angular/core'; import { Component, inject, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -14,10 +15,10 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
imports: [CommonModule, FormsModule, NgIcon], imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck, lucideCheck
}), })
], ],
templateUrl: './permissions-settings.component.html', templateUrl: './permissions-settings.component.html'
}) })
export class PermissionsSettingsComponent { export class PermissionsSettingsComponent {
private store = inject(Store); private store = inject(Store);
@@ -42,6 +43,7 @@ export class PermissionsSettingsComponent {
/** Load permissions from the server input. Called by parent via effect or on init. */ /** Load permissions from the server input. Called by parent via effect or on init. */
loadPermissions(room: Room): void { loadPermissions(room: Room): void {
const perms = room.permissions || {}; const perms = room.permissions || {};
this.allowVoice = perms.allowVoice !== false; this.allowVoice = perms.allowVoice !== false;
this.allowScreenShare = perms.allowScreenShare !== false; this.allowScreenShare = perms.allowScreenShare !== false;
this.allowFileUploads = perms.allowFileUploads !== false; this.allowFileUploads = perms.allowFileUploads !== false;
@@ -54,7 +56,10 @@ export class PermissionsSettingsComponent {
savePermissions(): void { savePermissions(): void {
const room = this.server(); const room = this.server();
if (!room) return;
if (!room)
return;
this.store.dispatch( this.store.dispatch(
RoomsActions.updateRoomPermissions({ RoomsActions.updateRoomPermissions({
roomId: room.id, roomId: room.id,
@@ -66,16 +71,19 @@ export class PermissionsSettingsComponent {
adminsManageRooms: this.adminsManageRooms, adminsManageRooms: this.adminsManageRooms,
moderatorsManageRooms: this.moderatorsManageRooms, moderatorsManageRooms: this.moderatorsManageRooms,
adminsManageIcon: this.adminsManageIcon, adminsManageIcon: this.adminsManageIcon,
moderatorsManageIcon: this.moderatorsManageIcon, moderatorsManageIcon: this.moderatorsManageIcon
}, }
}), })
); );
this.showSaveSuccess('permissions'); this.showSaveSuccess('permissions');
} }
private showSaveSuccess(key: string): void { private showSaveSuccess(key: string): void {
this.saveSuccess.set(key); this.saveSuccess.set(key);
if (this.saveTimeout) clearTimeout(this.saveTimeout);
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000); this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
} }
} }

View File

@@ -10,22 +10,24 @@
} }
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label> <label for="room-name" class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label>
<input <input
type="text" type="text"
[(ngModel)]="roomName" [(ngModel)]="roomName"
[readOnly]="!isAdmin()" [readOnly]="!isAdmin()"
id="room-name"
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" 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"
[class.opacity-60]="!isAdmin()" [class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()" [class.cursor-not-allowed]="!isAdmin()"
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Description</label> <label for="room-description" class="block text-xs font-medium text-muted-foreground mb-1">Description</label>
<textarea <textarea
[(ngModel)]="roomDescription" [(ngModel)]="roomDescription"
[readOnly]="!isAdmin()" [readOnly]="!isAdmin()"
rows="3" rows="3"
id="room-description"
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 resize-none" 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 resize-none"
[class.opacity-60]="!isAdmin()" [class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()" [class.cursor-not-allowed]="!isAdmin()"
@@ -39,6 +41,7 @@
</div> </div>
<button <button
(click)="togglePrivate()" (click)="togglePrivate()"
type="button"
class="p-2 rounded-lg transition-colors" class="p-2 rounded-lg transition-colors"
[class.bg-primary]="isPrivate()" [class.bg-primary]="isPrivate()"
[class.text-primary-foreground]="isPrivate()" [class.text-primary-foreground]="isPrivate()"
@@ -62,14 +65,15 @@
</div> </div>
} }
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1" <label for="room-max-users" class="block text-xs font-medium text-muted-foreground mb-1">
>Max Users (0 = unlimited)</label Max Users (0 = unlimited)
> </label>
<input <input
type="number" type="number"
[(ngModel)]="maxUsers" [(ngModel)]="maxUsers"
[readOnly]="!isAdmin()" [readOnly]="!isAdmin()"
min="0" min="0"
id="room-max-users"
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" 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"
[class.opacity-60]="!isAdmin()" [class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()" [class.cursor-not-allowed]="!isAdmin()"
@@ -81,6 +85,7 @@
@if (isAdmin()) { @if (isAdmin()) {
<button <button
(click)="saveServerSettings()" (click)="saveServerSettings()"
type="button"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm" class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm"
[class.bg-green-600]="saveSuccess() === 'server'" [class.bg-green-600]="saveSuccess() === 'server'"
[class.hover:bg-green-600]="saveSuccess() === 'server'" [class.hover:bg-green-600]="saveSuccess() === 'server'"
@@ -94,6 +99,7 @@
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4> <h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button <button
(click)="confirmDeleteRoom()" (click)="confirmDeleteRoom()"
type="button"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm" class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
> >
<ng-icon name="lucideTrash2" class="w-4 h-4" /> <ng-icon name="lucideTrash2" class="w-4 h-4" />

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input, signal, computed } from '@angular/core'; import { Component, inject, input, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -19,10 +20,10 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
lucideCheck, lucideCheck,
lucideTrash2, lucideTrash2,
lucideLock, lucideLock,
lucideUnlock, lucideUnlock
}), })
], ],
templateUrl: './server-settings.component.html', templateUrl: './server-settings.component.html'
}) })
export class ServerSettingsComponent { export class ServerSettingsComponent {
private store = inject(Store); private store = inject(Store);
@@ -45,22 +46,27 @@ export class ServerSettingsComponent {
/** Reload form fields whenever the server input changes. */ /** Reload form fields whenever the server input changes. */
serverData = computed(() => { serverData = computed(() => {
const room = this.server(); const room = this.server();
if (room) { if (room) {
this.roomName = room.name; this.roomName = room.name;
this.roomDescription = room.description || ''; this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate); this.isPrivate.set(room.isPrivate);
this.maxUsers = room.maxUsers || 0; this.maxUsers = room.maxUsers || 0;
} }
return room; return room;
}); });
togglePrivate(): void { togglePrivate(): void {
this.isPrivate.update((v) => !v); this.isPrivate.update((currentValue) => !currentValue);
} }
saveServerSettings(): void { saveServerSettings(): void {
const room = this.server(); const room = this.server();
if (!room) return;
if (!room)
return;
this.store.dispatch( this.store.dispatch(
RoomsActions.updateRoom({ RoomsActions.updateRoom({
roomId: room.id, roomId: room.id,
@@ -68,9 +74,9 @@ export class ServerSettingsComponent {
name: this.roomName, name: this.roomName,
description: this.roomDescription, description: this.roomDescription,
isPrivate: this.isPrivate(), isPrivate: this.isPrivate(),
maxUsers: this.maxUsers, maxUsers: this.maxUsers
}, }
}), })
); );
this.showSaveSuccess('server'); this.showSaveSuccess('server');
} }
@@ -81,7 +87,10 @@ export class ServerSettingsComponent {
deleteRoom(): void { deleteRoom(): void {
const room = this.server(); const room = this.server();
if (!room) return;
if (!room)
return;
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id })); this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
this.showDeleteConfirm.set(false); this.showDeleteConfirm.set(false);
this.modal.navigate('network'); this.modal.navigate('network');
@@ -89,7 +98,10 @@ export class ServerSettingsComponent {
private showSaveSuccess(key: string): void { private showSaveSuccess(key: string): void {
this.saveSuccess.set(key); this.saveSuccess.set(key);
if (this.saveTimeout) clearTimeout(this.saveTimeout);
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000); this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
} }
} }

View File

@@ -5,6 +5,11 @@
[class.opacity-100]="animating()" [class.opacity-100]="animating()"
[class.opacity-0]="!animating()" [class.opacity-0]="!animating()"
(click)="onBackdropClick()" (click)="onBackdropClick()"
(keydown.enter)="onBackdropClick()"
(keydown.space)="onBackdropClick()"
role="button"
tabindex="0"
aria-label="Close settings"
></div> ></div>
<!-- Modal --> <!-- Modal -->
@@ -16,6 +21,11 @@
[class.scale-95]="!animating()" [class.scale-95]="!animating()"
[class.opacity-0]="!animating()" [class.opacity-0]="!animating()"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
> >
<!-- Side Navigation --> <!-- Side Navigation -->
<nav class="w-52 flex-shrink-0 bg-secondary/40 border-r border-border flex flex-col"> <nav class="w-52 flex-shrink-0 bg-secondary/40 border-r border-border flex flex-col">
@@ -33,6 +43,7 @@
@for (page of globalPages; track page.id) { @for (page of globalPages; track page.id) {
<button <button
(click)="navigate(page.id)" (click)="navigate(page.id)"
type="button"
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors" class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
[class.bg-primary/10]="activePage() === page.id" [class.bg-primary/10]="activePage() === page.id"
[class.text-primary]="activePage() === page.id" [class.text-primary]="activePage() === page.id"
@@ -72,6 +83,7 @@
@for (page of serverPages; track page.id) { @for (page of serverPages; track page.id) {
<button <button
(click)="navigate(page.id)" (click)="navigate(page.id)"
type="button"
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors" class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
[class.bg-primary/10]="activePage() === page.id" [class.bg-primary/10]="activePage() === page.id"
[class.text-primary]="activePage() === page.id" [class.text-primary]="activePage() === page.id"
@@ -119,6 +131,7 @@
</h3> </h3>
<button <button
(click)="close()" (click)="close()"
type="button"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground" class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
> >
<ng-icon name="lucideX" class="w-5 h-5" /> <ng-icon name="lucideX" class="w-5 h-5" />

View File

@@ -1,13 +1,12 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
signal, signal,
computed, computed,
OnInit,
OnDestroy,
effect, effect,
HostListener, HostListener,
viewChild, viewChild
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -20,13 +19,13 @@ import {
lucideSettings, lucideSettings,
lucideUsers, lucideUsers,
lucideBan, lucideBan,
lucideShield, lucideShield
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { import {
selectCurrentUser, selectCurrentUser
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { Room } from '../../../core/models'; import { Room } from '../../../core/models';
@@ -49,7 +48,7 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
ServerSettingsComponent, ServerSettingsComponent,
MembersSettingsComponent, MembersSettingsComponent,
BansSettingsComponent, BansSettingsComponent,
PermissionsSettingsComponent, PermissionsSettingsComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
@@ -59,12 +58,12 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
lucideSettings, lucideSettings,
lucideUsers, lucideUsers,
lucideBan, lucideBan,
lucideShield, lucideShield
}), })
], ],
templateUrl: './settings-modal.component.html', templateUrl: './settings-modal.component.html'
}) })
export class SettingsModalComponent implements OnInit, OnDestroy { export class SettingsModalComponent {
readonly modal = inject(SettingsModalService); readonly modal = inject(SettingsModalService);
private store = inject(Store); private store = inject(Store);
@@ -82,21 +81,24 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
// --- Side-nav items --- // --- Side-nav items ---
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'network', label: 'Network', icon: 'lucideGlobe' }, { id: 'network', label: 'Network', icon: 'lucideGlobe' },
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }, { id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }
]; ];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server', label: 'Server', icon: 'lucideSettings' }, { id: 'server', label: 'Server', icon: 'lucideSettings' },
{ id: 'members', label: 'Members', icon: 'lucideUsers' }, { id: 'members', label: 'Members', icon: 'lucideUsers' },
{ id: 'bans', label: 'Bans', icon: 'lucideBan' }, { id: 'bans', label: 'Bans', icon: 'lucideBan' },
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }, { id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
]; ];
// ===== SERVER SELECTOR ===== // ===== SERVER SELECTOR =====
selectedServerId = signal<string | null>(null); selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => { selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId(); const id = this.selectedServerId();
if (!id) return null;
return this.savedRooms().find((r) => r.id === id) ?? null; if (!id)
return null;
return this.savedRooms().find((room) => room.id === id) ?? null;
}); });
/** Whether the user can see server-admin tabs. */ /** Whether the user can see server-admin tabs. */
@@ -108,7 +110,10 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
isSelectedServerAdmin = computed(() => { isSelectedServerAdmin = computed(() => {
const server = this.selectedServer(); const server = this.selectedServer();
const user = this.currentUser(); const user = this.currentUser();
if (!server || !user) return false;
if (!server || !user)
return false;
return server.hostId === user.id || server.hostId === user.oderId; return server.hostId === user.id || server.hostId === user.oderId;
}); });
@@ -120,11 +125,17 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
effect(() => { effect(() => {
if (this.isOpen()) { if (this.isOpen()) {
const targetId = this.modal.targetServerId(); const targetId = this.modal.targetServerId();
if (targetId) { if (targetId) {
this.selectedServerId.set(targetId); this.selectedServerId.set(targetId);
} else if (this.currentRoom()) { } else {
this.selectedServerId.set(this.currentRoom()!.id); const currentRoom = this.currentRoom();
if (currentRoom) {
this.selectedServerId.set(currentRoom.id);
}
} }
this.animating.set(true); this.animating.set(true);
} }
}); });
@@ -132,19 +143,16 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
// When selected server changes, reload permissions data // When selected server changes, reload permissions data
effect(() => { effect(() => {
const server = this.selectedServer(); const server = this.selectedServer();
if (server) { if (server) {
const permsComp = this.permissionsComponent(); const permsComp = this.permissionsComponent();
if (permsComp) { if (permsComp) {
permsComp.loadPermissions(server); permsComp.loadPermissions(server);
} }
} }
}); });
} }
ngOnInit(): void {}
ngOnDestroy(): void {}
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
onEscapeKey(): void { onEscapeKey(): void {
if (this.isOpen()) { if (this.isOpen()) {
@@ -168,6 +176,7 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
onServerSelect(event: Event): void { onServerSelect(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedServerId.set(select.value || null); this.selectedServerId.set(select.value || null);
} }
} }

View File

@@ -7,9 +7,10 @@
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label> <label for="input-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label>
<select <select
(change)="onInputDeviceChange($event)" (change)="onInputDeviceChange($event)"
id="input-device-select"
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" 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"
> >
@for (device of inputDevices(); track device.deviceId) { @for (device of inputDevices(); track device.deviceId) {
@@ -23,9 +24,10 @@
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label> <label for="output-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label>
<select <select
(change)="onOutputDeviceChange($event)" (change)="onOutputDeviceChange($event)"
id="output-device-select"
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" 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"
> >
@for (device of outputDevices(); track device.deviceId) { @for (device of outputDevices(); track device.deviceId) {
@@ -49,7 +51,7 @@
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1"> <label for="input-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
Input Volume: {{ inputVolume() }}% Input Volume: {{ inputVolume() }}%
</label> </label>
<input <input
@@ -58,11 +60,12 @@
(input)="onInputVolumeChange($event)" (input)="onInputVolumeChange($event)"
min="0" min="0"
max="100" max="100"
id="input-volume-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1"> <label for="output-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
Output Volume: {{ outputVolume() }}% Output Volume: {{ outputVolume() }}%
</label> </label>
<input <input
@@ -71,11 +74,12 @@
(input)="onOutputVolumeChange($event)" (input)="onOutputVolumeChange($event)"
min="0" min="0"
max="100" max="100"
id="output-volume-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1"> <label for="notification-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}% Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -86,10 +90,12 @@
min="0" min="0"
max="1" max="1"
step="0.01" step="0.01"
id="notification-volume-slider"
class="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" class="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />
<button <button
(click)="previewNotificationSound()" (click)="previewNotificationSound()"
type="button"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors flex-shrink-0" class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors flex-shrink-0"
title="Preview notification sound" title="Preview notification sound"
> >
@@ -111,9 +117,10 @@
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label> <label for="latency-profile-select" class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label>
<select <select
(change)="onLatencyProfileChange($event)" (change)="onLatencyProfileChange($event)"
id="latency-profile-select"
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" 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"
> >
<option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option> <option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option>
@@ -122,7 +129,7 @@
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-muted-foreground mb-1"> <label for="audio-bitrate-slider" class="block text-xs font-medium text-muted-foreground mb-1">
Audio Bitrate: {{ audioBitrate() }} kbps Audio Bitrate: {{ audioBitrate() }} kbps
</label> </label>
<input <input
@@ -132,6 +139,7 @@
min="32" min="32"
max="256" max="256"
step="8" step="8"
id="audio-bitrate-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />
</div> </div>
@@ -145,6 +153,8 @@
type="checkbox" type="checkbox"
[checked]="noiseReduction()" [checked]="noiseReduction()"
(change)="onNoiseReductionChange()" (change)="onNoiseReductionChange()"
id="noise-reduction-toggle"
aria-label="Toggle noise reduction"
class="sr-only peer" class="sr-only peer"
/> />
<div <div
@@ -162,6 +172,8 @@
type="checkbox" type="checkbox"
[checked]="includeSystemAudio()" [checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)" (change)="onIncludeSystemAudioChange($event)"
id="system-audio-toggle"
aria-label="Toggle system audio in screen share"
class="sr-only peer" class="sr-only peer"
/> />
<div <div
@@ -190,6 +202,8 @@
type="checkbox" type="checkbox"
[checked]="voiceLeveling.enabled()" [checked]="voiceLeveling.enabled()"
(change)="onVoiceLevelingToggle()" (change)="onVoiceLevelingToggle()"
id="voice-leveling-toggle"
aria-label="Toggle voice leveling"
class="sr-only peer" class="sr-only peer"
/> />
<div <div
@@ -203,7 +217,7 @@
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1"> <div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
<!-- Target Loudness --> <!-- Target Loudness -->
<div class="pl-3"> <div class="pl-3">
<label class="block text-xs font-medium text-muted-foreground mb-1"> <label for="target-loudness-slider" class="block text-xs font-medium text-muted-foreground mb-1">
Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS
</label> </label>
<input <input
@@ -213,6 +227,7 @@
min="-30" min="-30"
max="-12" max="-12"
step="1" step="1"
id="target-loudness-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />
<div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5"> <div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5">
@@ -223,9 +238,10 @@
<!-- AGC Strength --> <!-- AGC Strength -->
<div class="pl-3"> <div class="pl-3">
<label class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label> <label for="agc-strength-select" class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label>
<select <select
(change)="onStrengthChange($event)" (change)="onStrengthChange($event)"
id="agc-strength-select"
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" 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"
> >
<option value="low" [selected]="voiceLeveling.strength() === 'low'"> <option value="low" [selected]="voiceLeveling.strength() === 'low'">
@@ -242,7 +258,7 @@
<!-- Max Gain Boost --> <!-- Max Gain Boost -->
<div class="pl-3"> <div class="pl-3">
<label class="block text-xs font-medium text-muted-foreground mb-1"> <label for="max-gain-slider" class="block text-xs font-medium text-muted-foreground mb-1">
Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB
</label> </label>
<input <input
@@ -252,6 +268,7 @@
min="3" min="3"
max="20" max="20"
step="1" step="1"
id="max-gain-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />
<div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5"> <div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5">
@@ -262,11 +279,12 @@
<!-- Response Speed --> <!-- Response Speed -->
<div class="pl-3"> <div class="pl-3">
<label class="block text-xs font-medium text-muted-foreground mb-1"> <label for="response-speed-select" class="block text-xs font-medium text-muted-foreground mb-1">
Response Speed Response Speed
</label> </label>
<select <select
(change)="onSpeedChange($event)" (change)="onSpeedChange($event)"
id="response-speed-select"
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" 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"
> >
<option value="slow" [selected]="voiceLeveling.speed() === 'slow'"> <option value="slow" [selected]="voiceLeveling.speed() === 'slow'">
@@ -290,6 +308,8 @@
type="checkbox" type="checkbox"
[checked]="voiceLeveling.noiseGate()" [checked]="voiceLeveling.noiseGate()"
(change)="onNoiseGateToggle()" (change)="onNoiseGateToggle()"
id="noise-gate-toggle"
aria-label="Toggle noise floor gate"
class="sr-only peer" class="sr-only peer"
/> />
<div <div

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -8,7 +9,7 @@ import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service'; import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
import { import {
NotificationAudioService, NotificationAudioService,
AppSound, AppSound
} from '../../../../core/services/notification-audio.service'; } from '../../../../core/services/notification-audio.service';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants'; import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
@@ -26,10 +27,10 @@ interface AudioDevice {
lucideMic, lucideMic,
lucideHeadphones, lucideHeadphones,
lucideAudioLines, lucideAudioLines,
lucideActivity, lucideActivity
}), })
], ],
templateUrl: './voice-settings.component.html', templateUrl: './voice-settings.component.html'
}) })
export class VoiceSettingsComponent { export class VoiceSettingsComponent {
private webrtcService = inject(WebRTCService); private webrtcService = inject(WebRTCService);
@@ -54,17 +55,20 @@ export class VoiceSettingsComponent {
async loadAudioDevices(): Promise<void> { async loadAudioDevices(): Promise<void> {
try { try {
if (!navigator.mediaDevices?.enumerateDevices) return; if (!navigator.mediaDevices?.enumerateDevices)
return;
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set( this.inputDevices.set(
devices devices
.filter((d) => d.kind === 'audioinput') .filter((device) => device.kind === 'audioinput')
.map((d) => ({ deviceId: d.deviceId, label: d.label })), .map((device) => ({ deviceId: device.deviceId, label: device.label }))
); );
this.outputDevices.set( this.outputDevices.set(
devices devices
.filter((d) => d.kind === 'audiooutput') .filter((device) => device.kind === 'audiooutput')
.map((d) => ({ deviceId: d.deviceId, label: d.label })), .map((device) => ({ deviceId: device.deviceId, label: device.label }))
); );
} catch {} } catch {}
} }
@@ -72,18 +76,37 @@ export class VoiceSettingsComponent {
loadVoiceSettings(): void { loadVoiceSettings(): void {
try { try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS); const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (!raw) return;
const s = JSON.parse(raw); if (!raw)
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice); return;
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume); const settings = JSON.parse(raw);
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate); if (settings.inputDevice)
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile); this.selectedInputDevice.set(settings.inputDevice);
if (typeof s.includeSystemAudio === 'boolean')
this.includeSystemAudio.set(s.includeSystemAudio); if (settings.outputDevice)
if (typeof s.noiseReduction === 'boolean') this.noiseReduction.set(s.noiseReduction); this.selectedOutputDevice.set(settings.outputDevice);
if (typeof settings.inputVolume === 'number')
this.inputVolume.set(settings.inputVolume);
if (typeof settings.outputVolume === 'number')
this.outputVolume.set(settings.outputVolume);
if (typeof settings.audioBitrate === 'number')
this.audioBitrate.set(settings.audioBitrate);
if (settings.latencyProfile)
this.latencyProfile.set(settings.latencyProfile);
if (typeof settings.includeSystemAudio === 'boolean')
this.includeSystemAudio.set(settings.includeSystemAudio);
if (typeof settings.noiseReduction === 'boolean')
this.noiseReduction.set(settings.noiseReduction);
} catch {} } catch {}
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) { if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) {
this.webrtcService.toggleNoiseReduction(this.noiseReduction()); this.webrtcService.toggleNoiseReduction(this.noiseReduction());
} }
@@ -101,20 +124,22 @@ export class VoiceSettingsComponent {
audioBitrate: this.audioBitrate(), audioBitrate: this.audioBitrate(),
latencyProfile: this.latencyProfile(), latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(), includeSystemAudio: this.includeSystemAudio(),
noiseReduction: this.noiseReduction(), noiseReduction: this.noiseReduction()
}), })
); );
} catch {} } catch {}
} }
onInputDeviceChange(event: Event): void { onInputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedInputDevice.set(select.value); this.selectedInputDevice.set(select.value);
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
onOutputDeviceChange(event: Event): void { onOutputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value); this.selectedOutputDevice.set(select.value);
this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings(); this.saveVoiceSettings();
@@ -122,12 +147,14 @@ export class VoiceSettingsComponent {
onInputVolumeChange(event: Event): void { onInputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10)); this.inputVolume.set(parseInt(input.value, 10));
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
onOutputVolumeChange(event: Event): void { onOutputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.outputVolume.set(parseInt(input.value, 10)); this.outputVolume.set(parseInt(input.value, 10));
this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings(); this.saveVoiceSettings();
@@ -136,6 +163,7 @@ export class VoiceSettingsComponent {
onLatencyProfileChange(event: Event): void { onLatencyProfileChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
const profile = select.value as 'low' | 'balanced' | 'high'; const profile = select.value as 'low' | 'balanced' | 'high';
this.latencyProfile.set(profile); this.latencyProfile.set(profile);
this.webrtcService.setLatencyProfile(profile); this.webrtcService.setLatencyProfile(profile);
this.saveVoiceSettings(); this.saveVoiceSettings();
@@ -143,6 +171,7 @@ export class VoiceSettingsComponent {
onAudioBitrateChange(event: Event): void { onAudioBitrateChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.audioBitrate.set(parseInt(input.value, 10)); this.audioBitrate.set(parseInt(input.value, 10));
this.webrtcService.setAudioBitrate(this.audioBitrate()); this.webrtcService.setAudioBitrate(this.audioBitrate());
this.saveVoiceSettings(); this.saveVoiceSettings();
@@ -150,12 +179,13 @@ export class VoiceSettingsComponent {
onIncludeSystemAudioChange(event: Event): void { onIncludeSystemAudioChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.includeSystemAudio.set(!!input.checked); this.includeSystemAudio.set(!!input.checked);
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
async onNoiseReductionChange(): Promise<void> { async onNoiseReductionChange(): Promise<void> {
this.noiseReduction.update((v) => !v); this.noiseReduction.update((currentValue) => !currentValue);
await this.webrtcService.toggleNoiseReduction(this.noiseReduction()); await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
this.saveVoiceSettings(); this.saveVoiceSettings();
} }
@@ -168,21 +198,25 @@ export class VoiceSettingsComponent {
onTargetDbfsChange(event: Event): void { onTargetDbfsChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.voiceLeveling.setTargetDbfs(parseInt(input.value, 10)); this.voiceLeveling.setTargetDbfs(parseInt(input.value, 10));
} }
onStrengthChange(event: Event): void { onStrengthChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.voiceLeveling.setStrength(select.value as 'low' | 'medium' | 'high'); this.voiceLeveling.setStrength(select.value as 'low' | 'medium' | 'high');
} }
onMaxGainDbChange(event: Event): void { onMaxGainDbChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.voiceLeveling.setMaxGainDb(parseInt(input.value, 10)); this.voiceLeveling.setMaxGainDb(parseInt(input.value, 10));
} }
onSpeedChange(event: Event): void { onSpeedChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.voiceLeveling.setSpeed(select.value as 'slow' | 'medium' | 'fast'); this.voiceLeveling.setSpeed(select.value as 'slow' | 'medium' | 'fast');
} }
@@ -192,6 +226,7 @@ export class VoiceSettingsComponent {
onNotificationVolumeChange(event: Event): void { onNotificationVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.audioService.setNotificationVolume(parseFloat(input.value)); this.audioService.setNotificationVolume(parseFloat(input.value));
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal, OnInit } from '@angular/core'; import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -13,7 +14,7 @@ import {
lucideRefreshCw, lucideRefreshCw,
lucideGlobe, lucideGlobe,
lucideArrowLeft, lucideArrowLeft,
lucideAudioLines, lucideAudioLines
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { ServerDirectoryService } from '../../core/services/server-directory.service';
@@ -36,10 +37,10 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
lucideRefreshCw, lucideRefreshCw,
lucideGlobe, lucideGlobe,
lucideArrowLeft, lucideArrowLeft,
lucideAudioLines, lucideAudioLines
}), })
], ],
templateUrl: './settings.component.html', templateUrl: './settings.component.html'
}) })
/** /**
* Settings page for managing signaling servers and connection preferences. * Settings page for managing signaling servers and connection preferences.
@@ -86,7 +87,7 @@ export class SettingsComponent implements OnInit {
this.serverDirectory.addServer({ this.serverDirectory.addServer({
name: this.newServerName.trim(), name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, ''), // Remove trailing slash url: this.newServerUrl.trim().replace(/\/$/, '') // Remove trailing slash
}); });
// Clear form // Clear form
@@ -96,6 +97,7 @@ export class SettingsComponent implements OnInit {
// Test the new server // Test the new server
const servers = this.servers(); const servers = this.servers();
const newServer = servers[servers.length - 1]; const newServer = servers[servers.length - 1];
if (newServer) { if (newServer) {
this.serverDirectory.testServer(newServer.id); this.serverDirectory.testServer(newServer.id);
} }
@@ -121,8 +123,10 @@ export class SettingsComponent implements OnInit {
/** Load connection settings (auto-reconnect, search scope) from localStorage. */ /** Load connection settings (auto-reconnect, search scope) from localStorage. */
loadConnectionSettings(): void { loadConnectionSettings(): void {
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS); const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (settings) { if (settings) {
const parsed = JSON.parse(settings); const parsed = JSON.parse(settings);
this.autoReconnect = parsed.autoReconnect ?? true; this.autoReconnect = parsed.autoReconnect ?? true;
this.searchAllServers = parsed.searchAllServers ?? true; this.searchAllServers = parsed.searchAllServers ?? true;
this.serverDirectory.setSearchAllServers(this.searchAllServers); this.serverDirectory.setSearchAllServers(this.searchAllServers);
@@ -135,8 +139,8 @@ export class SettingsComponent implements OnInit {
STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_CONNECTION_SETTINGS,
JSON.stringify({ JSON.stringify({
autoReconnect: this.autoReconnect, autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers, searchAllServers: this.searchAllServers
}), })
); );
this.serverDirectory.setSearchAllServers(this.searchAllServers); this.serverDirectory.setSearchAllServers(this.searchAllServers);
} }
@@ -149,10 +153,13 @@ export class SettingsComponent implements OnInit {
/** Load voice settings (noise reduction) from localStorage. */ /** Load voice settings (noise reduction) from localStorage. */
loadVoiceSettings(): void { loadVoiceSettings(): void {
const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS); const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (settings) { if (settings) {
const parsed = JSON.parse(settings); const parsed = JSON.parse(settings);
this.noiseReduction = parsed.noiseReduction ?? false; this.noiseReduction = parsed.noiseReduction ?? false;
} }
// Sync the live WebRTC state with the persisted preference // Sync the live WebRTC state with the persisted preference
if (this.noiseReduction !== this.webrtcService.isNoiseReductionEnabled()) { if (this.noiseReduction !== this.webrtcService.isNoiseReductionEnabled()) {
this.webrtcService.toggleNoiseReduction(this.noiseReduction); this.webrtcService.toggleNoiseReduction(this.noiseReduction);
@@ -173,13 +180,17 @@ export class SettingsComponent implements OnInit {
async saveVoiceSettings(): Promise<void> { async saveVoiceSettings(): Promise<void> {
// Merge into existing voice settings so we don't overwrite device/volume prefs // Merge into existing voice settings so we don't overwrite device/volume prefs
let existing: Record<string, unknown> = {}; let existing: Record<string, unknown> = {};
try { try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS); const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (raw) existing = JSON.parse(raw);
if (raw)
existing = JSON.parse(raw);
} catch {} } catch {}
localStorage.setItem( localStorage.setItem(
STORAGE_KEY_VOICE_SETTINGS, STORAGE_KEY_VOICE_SETTINGS,
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction }), JSON.stringify({ ...existing, noiseReduction: this.noiseReduction })
); );
await this.webrtcService.toggleNoiseReduction(this.noiseReduction); await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
} }

View File

@@ -4,7 +4,7 @@
> >
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;"> <div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
@if (inRoom()) { @if (inRoom()) {
<button (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back"> <button type="button" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" /> <ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
</button> </button>
} }
@@ -16,13 +16,14 @@
{{ roomDescription() }} {{ roomDescription() }}
</span> </span>
} }
<button (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu"> <button type="button" (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" /> <ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
</button> </button>
<!-- Anchored dropdown under the menu button --> <!-- Anchored dropdown under the menu button -->
@if (showMenu()) { @if (showMenu()) {
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48"> <div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
<button <button
type="button"
(click)="leaveServer()" (click)="leaveServer()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
> >
@@ -30,6 +31,7 @@
</button> </button>
<div class="border-t border-border"></div> <div class="border-t border-border"></div>
<button <button
type="button"
(click)="logout()" (click)="logout()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
> >
@@ -49,6 +51,7 @@
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;"> <div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
@if (!isAuthed()) { @if (!isAuthed()) {
<button <button
type="button"
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground" class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
(click)="goLogin()" (click)="goLogin()"
title="Login" title="Login"
@@ -57,13 +60,13 @@
</button> </button>
} }
@if (isElectron()) { @if (isElectron()) {
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()"> <button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
<ng-icon name="lucideMinus" class="w-4 h-4" /> <ng-icon name="lucideMinus" class="w-4 h-4" />
</button> </button>
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()"> <button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
<ng-icon name="lucideSquare" class="w-4 h-4" /> <ng-icon name="lucideSquare" class="w-4 h-4" />
</button> </button>
<button class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()"> <button type="button" class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" /> <ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
</button> </button>
} }
@@ -71,5 +74,14 @@
</div> </div>
<!-- Click-away overlay to close dropdown --> <!-- Click-away overlay to close dropdown -->
@if (showMenu()) { @if (showMenu()) {
<div class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div> <div
class="fixed inset-0 z-40"
(click)="closeMenu()"
(keydown.enter)="closeMenu()"
(keydown.space)="closeMenu()"
tabindex="0"
role="button"
aria-label="Close menu overlay"
style="-webkit-app-region: no-drag;"
></div>
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, computed, signal } from '@angular/core'; import { Component, inject, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
standalone: true, standalone: true,
imports: [CommonModule, NgIcon], imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })], viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
templateUrl: './title-bar.component.html', templateUrl: './title-bar.component.html'
}) })
/** /**
* Electron-style title bar with window controls, navigation, and server menu. * Electron-style title bar with window controls, navigation, and server menu.
@@ -48,19 +49,25 @@ export class TitleBarComponent {
/** Minimize the Electron window. */ /** Minimize the Electron window. */
minimize() { minimize() {
const api = (window as any).electronAPI; const api = (window as any).electronAPI;
if (api?.minimizeWindow) api.minimizeWindow();
if (api?.minimizeWindow)
api.minimizeWindow();
} }
/** Maximize or restore the Electron window. */ /** Maximize or restore the Electron window. */
maximize() { maximize() {
const api = (window as any).electronAPI; const api = (window as any).electronAPI;
if (api?.maximizeWindow) api.maximizeWindow();
if (api?.maximizeWindow)
api.maximizeWindow();
} }
/** Close the Electron window. */ /** Close the Electron window. */
close() { close() {
const api = (window as any).electronAPI; const api = (window as any).electronAPI;
if (api?.closeWindow) api.closeWindow();
if (api?.closeWindow)
api.closeWindow();
} }
/** Navigate to the login page. */ /** Navigate to the login page. */
@@ -98,9 +105,11 @@ export class TitleBarComponent {
// Disconnect from signaling server this broadcasts "user_left" to all // Disconnect from signaling server this broadcasts "user_left" to all
// servers the user was a member of, so other users see them go offline. // servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect(); this.webrtc.disconnect();
try { try {
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID); localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
} catch {} } catch {}
this.router.navigate(['/login']); this.router.navigate(['/login']);
} }
} }

View File

@@ -5,6 +5,7 @@
<!-- Back to server button --> <!-- Back to server button -->
<button <button
(click)="navigateToServer()" (click)="navigateToServer()"
type="button"
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors" class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
title="Back to {{ voiceSession()?.serverName }}" title="Back to {{ voiceSession()?.serverName }}"
> >
@@ -35,6 +36,7 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <button
(click)="toggleMute()" (click)="toggleMute()"
type="button"
[class]="getCompactButtonClass(isMuted())" [class]="getCompactButtonClass(isMuted())"
title="Toggle Mute" title="Toggle Mute"
> >
@@ -43,6 +45,7 @@
<button <button
(click)="toggleDeafen()" (click)="toggleDeafen()"
type="button"
[class]="getCompactButtonClass(isDeafened())" [class]="getCompactButtonClass(isDeafened())"
title="Toggle Deafen" title="Toggle Deafen"
> >
@@ -51,6 +54,7 @@
<button <button
(click)="toggleScreenShare()" (click)="toggleScreenShare()"
type="button"
[class]="getCompactScreenShareClass()" [class]="getCompactScreenShareClass()"
title="Toggle Screen Share" title="Toggle Screen Share"
> >
@@ -59,6 +63,7 @@
<button <button
(click)="disconnect()" (click)="disconnect()"
type="button"
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors" class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
title="Disconnect" title="Disconnect"
> >

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core'; import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -10,7 +11,7 @@ import {
lucideMonitorOff, lucideMonitorOff,
lucidePhoneOff, lucidePhoneOff,
lucideHeadphones, lucideHeadphones,
lucideArrowLeft, lucideArrowLeft
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
@@ -25,12 +26,13 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideMic, lucideMic,
lucideMicOff,
lucideMonitor, lucideMonitor,
lucideMonitorOff, lucideMonitorOff,
lucidePhoneOff, lucidePhoneOff,
lucideHeadphones, lucideHeadphones,
lucideArrowLeft, lucideArrowLeft
}), })
], ],
templateUrl: './floating-voice-controls.component.html' templateUrl: './floating-voice-controls.component.html'
}) })
@@ -86,8 +88,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
voiceState: { voiceState: {
isConnected: this.isConnected(), isConnected: this.isConnected(),
isMuted: this.isMuted(), isMuted: this.isMuted(),
isDeafened: this.isDeafened(), isDeafened: this.isDeafened()
}, }
}); });
} }
@@ -110,8 +112,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
voiceState: { voiceState: {
isConnected: this.isConnected(), isConnected: this.isConnected(),
isMuted: this.isMuted(), isMuted: this.isMuted(),
isDeafened: this.isDeafened(), isDeafened: this.isDeafened()
}, }
}); });
} }
@@ -143,8 +145,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
voiceState: { voiceState: {
isConnected: false, isConnected: false,
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false
}, }
}); });
// Stop screen sharing if active // Stop screen sharing if active
@@ -157,6 +159,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
// Update user voice state in store // Update user voice state in store
const user = this.currentUser(); const user = this.currentUser();
if (user?.id) { if (user?.id) {
this.store.dispatch(UsersActions.updateVoiceState({ this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id, userId: user.id,
@@ -176,45 +179,55 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
/** Return the CSS classes for the compact control button based on active state. */ /** Return the CSS classes for the compact control button based on active state. */
getCompactButtonClass(isActive: boolean): string { getCompactButtonClass(isActive: boolean): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors'; const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (isActive) { if (isActive) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30'; return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
} }
return base + ' bg-secondary text-foreground hover:bg-secondary/80'; return base + ' bg-secondary text-foreground hover:bg-secondary/80';
} }
/** Return the CSS classes for the compact screen-share button. */ /** Return the CSS classes for the compact screen-share button. */
getCompactScreenShareClass(): string { getCompactScreenShareClass(): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors'; const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30'; return base + ' bg-primary/20 text-primary hover:bg-primary/30';
} }
return base + ' bg-secondary text-foreground hover:bg-secondary/80'; return base + ' bg-secondary text-foreground hover:bg-secondary/80';
} }
/** Return the CSS classes for the mute toggle button. */ /** Return the CSS classes for the mute toggle button. */
getMuteButtonClass(): string { getMuteButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors'; const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isMuted()) { if (this.isMuted()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30'; return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
} }
return base + ' bg-secondary text-foreground hover:bg-secondary/80'; return base + ' bg-secondary text-foreground hover:bg-secondary/80';
} }
/** Return the CSS classes for the deafen toggle button. */ /** Return the CSS classes for the deafen toggle button. */
getDeafenButtonClass(): string { getDeafenButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors'; const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isDeafened()) { if (this.isDeafened()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30'; return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
} }
return base + ' bg-secondary text-foreground hover:bg-secondary/80'; return base + ' bg-secondary text-foreground hover:bg-secondary/80';
} }
/** Return the CSS classes for the screen-share toggle button. */ /** Return the CSS classes for the screen-share toggle button. */
getScreenShareButtonClass(): string { getScreenShareButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors'; const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30'; return base + ' bg-primary/20 text-primary hover:bg-primary/30';
} }
return base + ' bg-secondary text-foreground hover:bg-secondary/80'; return base + ' bg-secondary text-foreground hover:bg-secondary/80';
} }
} }

View File

@@ -42,6 +42,7 @@
</div> </div>
<button <button
(click)="toggleFullscreen()" (click)="toggleFullscreen()"
type="button"
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors" class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
> >
@if (isFullscreen()) { @if (isFullscreen()) {
@@ -53,6 +54,7 @@
@if (isLocalShare()) { @if (isLocalShare()) {
<button <button
(click)="stopSharing()" (click)="stopSharing()"
type="button"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors" class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop sharing" title="Stop sharing"
> >
@@ -61,6 +63,7 @@
} @else { } @else {
<button <button
(click)="stopWatching()" (click)="stopWatching()"
type="button"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors" class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop watching" title="Stop watching"
> >

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core'; import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -7,7 +8,7 @@ import {
lucideMaximize, lucideMaximize,
lucideMinimize, lucideMinimize,
lucideX, lucideX,
lucideMonitor, lucideMonitor
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
@@ -24,10 +25,10 @@ import { DEFAULT_VOLUME } from '../../../core/constants';
lucideMaximize, lucideMaximize,
lucideMinimize, lucideMinimize,
lucideX, lucideX,
lucideMonitor, lucideMonitor
}), })
], ],
templateUrl: './screen-share-viewer.component.html', templateUrl: './screen-share-viewer.component.html'
}) })
/** /**
* Displays a local or remote screen-share stream in a video player. * Displays a local or remote screen-share stream in a video player.
@@ -54,9 +55,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => { private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
try { try {
const userId = evt.detail?.userId; const userId = evt.detail?.userId;
if (!userId) return;
if (!userId)
return;
const stream = this.webrtcService.getRemoteStream(userId); const stream = this.webrtcService.getRemoteStream(userId);
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null; const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) { if (stream && stream.getVideoTracks().length > 0) {
if (user) { if (user) {
this.setRemoteStream(stream, user); this.setRemoteStream(stream, user);
@@ -77,6 +82,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
// React to screen share stream changes // React to screen share stream changes
effect(() => { effect(() => {
const screenStream = this.webrtcService.screenStream(); const screenStream = this.webrtcService.screenStream();
if (screenStream && this.videoRef) { if (screenStream && this.videoRef) {
// Local share: always mute to avoid audio feedback // Local share: always mute to avoid audio feedback
this.videoRef.nativeElement.srcObject = screenStream; this.videoRef.nativeElement.srcObject = screenStream;
@@ -97,7 +103,8 @@ export class ScreenShareViewerComponent implements OnDestroy {
const isWatchingRemote = this.hasStream() && !this.isLocalShare(); const isWatchingRemote = this.hasStream() && !this.isLocalShare();
// Only check if we're actually watching a remote stream // Only check if we're actually watching a remote stream
if (!watchingId || !isWatchingRemote) return; if (!watchingId || !isWatchingRemote)
return;
const users = this.onlineUsers(); const users = this.onlineUsers();
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId); const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
@@ -111,6 +118,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
// Also check if the stream's video tracks are still available // Also check if the stream's video tracks are still available
const stream = this.webrtcService.getRemoteStream(watchingId); const stream = this.webrtcService.getRemoteStream(watchingId);
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live'); const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
if (!hasActiveVideo) { if (!hasActiveVideo) {
// Stream or video tracks are gone - stop watching // Stream or video tracks are gone - stop watching
this.stopWatching(); this.stopWatching();
@@ -153,6 +161,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
/** Enter fullscreen mode, requesting browser fullscreen if available. */ /** Enter fullscreen mode, requesting browser fullscreen if available. */
enterFullscreen(): void { enterFullscreen(): void {
this.isFullscreen.set(true); this.isFullscreen.set(true);
// Request browser fullscreen if available // Request browser fullscreen if available
if (this.videoRef?.nativeElement.requestFullscreen) { if (this.videoRef?.nativeElement.requestFullscreen) {
this.videoRef.nativeElement.requestFullscreen().catch(() => { this.videoRef.nativeElement.requestFullscreen().catch(() => {
@@ -164,6 +173,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
/** Exit fullscreen mode. */ /** Exit fullscreen mode. */
exitFullscreen(): void { exitFullscreen(): void {
this.isFullscreen.set(false); this.isFullscreen.set(false);
if (document.fullscreenElement) { if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {}); document.exitFullscreen().catch(() => {});
} }
@@ -183,10 +193,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
if (this.videoRef) { if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null; this.videoRef.nativeElement.srcObject = null;
} }
this.activeScreenSharer.set(null); this.activeScreenSharer.set(null);
this.watchingUserId.set(null); this.watchingUserId.set(null);
this.hasStream.set(false); this.hasStream.set(false);
this.isLocalShare.set(false); this.isLocalShare.set(false);
if (this.isFullscreen()) { if (this.isFullscreen()) {
this.exitFullscreen(); this.exitFullscreen();
} }
@@ -198,8 +210,10 @@ export class ScreenShareViewerComponent implements OnDestroy {
this.activeScreenSharer.set(user); this.activeScreenSharer.set(user);
this.watchingUserId.set(user.id || user.oderId || null); this.watchingUserId.set(user.id || user.oderId || null);
this.isLocalShare.set(false); this.isLocalShare.set(false);
if (this.videoRef) { if (this.videoRef) {
const el = this.videoRef.nativeElement; const el = this.videoRef.nativeElement;
el.srcObject = stream; el.srcObject = stream;
// For autoplay policies, try muted first, then unmute per volume setting // For autoplay policies, try muted first, then unmute per volume setting
el.muted = true; el.muted = true;
@@ -208,17 +222,19 @@ export class ScreenShareViewerComponent implements OnDestroy {
// After playback starts, apply viewer volume settings // After playback starts, apply viewer volume settings
el.volume = this.screenVolume() / 100; el.volume = this.screenVolume() / 100;
el.muted = this.screenVolume() === 0; el.muted = this.screenVolume() === 0;
}).catch(() => { })
.catch(() => {
// If autoplay fails, keep muted to allow play, then apply volume // If autoplay fails, keep muted to allow play, then apply volume
try { try {
el.muted = true; el.muted = true;
el.volume = 0; el.volume = 0;
el.play().then(() => { el.play().then(() => {
el.volume = this.screenVolume() / 100; el.volume = this.screenVolume() / 100;
el.muted = this.screenVolume() === 0; el.muted = this.screenVolume() === 0;
}).catch(() => {}); })
} catch {} .catch(() => {});
}); } catch {}
});
this.hasStream.set(true); this.hasStream.set(true);
} }
} }
@@ -228,6 +244,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
setLocalStream(stream: MediaStream, user: User): void { setLocalStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user); this.activeScreenSharer.set(user);
this.isLocalShare.set(true); this.isLocalShare.set(true);
if (this.videoRef) { if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream; this.videoRef.nativeElement.srcObject = stream;
// Always mute local share playback // Always mute local share playback
@@ -241,10 +258,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
onScreenVolumeChange(event: Event): void { onScreenVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const val = Math.max(0, Math.min(100, parseInt(input.value, 10))); const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));
this.screenVolume.set(val); this.screenVolume.set(val);
if (this.videoRef?.nativeElement) { if (this.videoRef?.nativeElement) {
// Volume applies only to remote streams; keep local share muted // Volume applies only to remote streams; keep local share muted
const isLocal = this.isLocalShare(); const isLocal = this.isLocalShare();
this.videoRef.nativeElement.volume = isLocal ? 0 : val / 100; this.videoRef.nativeElement.volume = isLocal ? 0 : val / 100;
this.videoRef.nativeElement.muted = isLocal ? true : val === 0; this.videoRef.nativeElement.muted = isLocal ? true : val === 0;
} }

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { WebRTCService } from '../../../../core/services/webrtc.service'; import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service'; import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
@@ -10,15 +10,12 @@ export interface PlaybackOptions {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoicePlaybackService { export class VoicePlaybackService {
private voiceLeveling = inject(VoiceLevelingService);
private webrtc = inject(WebRTCService);
private remoteAudioElements = new Map<string, HTMLAudioElement>(); private remoteAudioElements = new Map<string, HTMLAudioElement>();
private pendingRemoteStreams = new Map<string, MediaStream>(); private pendingRemoteStreams = new Map<string, MediaStream>();
private rawRemoteStreams = new Map<string, MediaStream>(); private rawRemoteStreams = new Map<string, MediaStream>();
constructor(
private voiceLeveling: VoiceLevelingService,
private webrtc: WebRTCService,
) {}
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void { handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
if (!options.isConnected) { if (!options.isConnected) {
this.pendingRemoteStreams.set(peerId, stream); this.pendingRemoteStreams.set(peerId, stream);
@@ -36,6 +33,7 @@ export class VoicePlaybackService {
// Start playback immediately with the raw stream // Start playback immediately with the raw stream
const audio = new Audio(); const audio = new Audio();
audio.srcObject = stream; audio.srcObject = stream;
audio.autoplay = true; audio.autoplay = true;
audio.volume = options.outputVolume; audio.volume = options.outputVolume;
@@ -47,10 +45,12 @@ export class VoicePlaybackService {
if (this.voiceLeveling.enabled()) { if (this.voiceLeveling.enabled()) {
this.voiceLeveling.enable(peerId, stream).then((leveledStream) => { this.voiceLeveling.enable(peerId, stream).then((leveledStream) => {
const currentAudio = this.remoteAudioElements.get(peerId); const currentAudio = this.remoteAudioElements.get(peerId);
if (currentAudio && leveledStream !== stream) { if (currentAudio && leveledStream !== stream) {
currentAudio.srcObject = leveledStream; currentAudio.srcObject = leveledStream;
} }
}).catch(() => {}); })
.catch(() => {});
} }
} }
@@ -62,18 +62,25 @@ export class VoicePlaybackService {
} }
playPendingStreams(options: PlaybackOptions): void { playPendingStreams(options: PlaybackOptions): void {
if (!options.isConnected) return; if (!options.isConnected)
return;
this.pendingRemoteStreams.forEach((stream, peerId) => this.handleRemoteStream(peerId, stream, options)); this.pendingRemoteStreams.forEach((stream, peerId) => this.handleRemoteStream(peerId, stream, options));
this.pendingRemoteStreams.clear(); this.pendingRemoteStreams.clear();
} }
ensureAllRemoteStreamsPlaying(options: PlaybackOptions): void { ensureAllRemoteStreamsPlaying(options: PlaybackOptions): void {
if (!options.isConnected) return; if (!options.isConnected)
return;
const peers = this.webrtc.getConnectedPeers(); const peers = this.webrtc.getConnectedPeers();
for (const peerId of peers) { for (const peerId of peers) {
const stream = this.webrtc.getRemoteStream(peerId); const stream = this.webrtc.getRemoteStream(peerId);
if (stream && this.hasAudio(stream)) { if (stream && this.hasAudio(stream)) {
const trackedRaw = this.rawRemoteStreams.get(peerId); const trackedRaw = this.rawRemoteStreams.get(peerId);
if (!trackedRaw || trackedRaw !== stream) { if (!trackedRaw || trackedRaw !== stream) {
this.handleRemoteStream(peerId, stream, options); this.handleRemoteStream(peerId, stream, options);
} }
@@ -87,6 +94,7 @@ export class VoicePlaybackService {
try { try {
const leveledStream = await this.voiceLeveling.enable(peerId, rawStream); const leveledStream = await this.voiceLeveling.enable(peerId, rawStream);
const audio = this.remoteAudioElements.get(peerId); const audio = this.remoteAudioElements.get(peerId);
if (audio && leveledStream !== rawStream) { if (audio && leveledStream !== rawStream) {
audio.srcObject = leveledStream; audio.srcObject = leveledStream;
} }
@@ -94,13 +102,16 @@ export class VoicePlaybackService {
} }
} else { } else {
this.voiceLeveling.disableAll(); this.voiceLeveling.disableAll();
for (const [peerId, rawStream] of this.rawRemoteStreams) { for (const [peerId, rawStream] of this.rawRemoteStreams) {
const audio = this.remoteAudioElements.get(peerId); const audio = this.remoteAudioElements.get(peerId);
if (audio) { if (audio) {
audio.srcObject = rawStream; audio.srcObject = rawStream;
} }
} }
} }
this.updateOutputVolume(options.outputVolume); this.updateOutputVolume(options.outputVolume);
this.updateDeafened(options.isDeafened); this.updateDeafened(options.isDeafened);
} }
@@ -118,9 +129,12 @@ export class VoicePlaybackService {
} }
applyOutputDevice(deviceId: string): void { applyOutputDevice(deviceId: string): void {
if (!deviceId) return; if (!deviceId)
return;
this.remoteAudioElements.forEach((audio) => { this.remoteAudioElements.forEach((audio) => {
const anyAudio = audio as any; const anyAudio = audio as any;
if (typeof anyAudio.setSinkId === 'function') { if (typeof anyAudio.setSinkId === 'function') {
anyAudio.setSinkId(deviceId).catch(() => {}); anyAudio.setSinkId(deviceId).catch(() => {});
} }
@@ -143,6 +157,7 @@ export class VoicePlaybackService {
private removeAudioElement(peerId: string): void { private removeAudioElement(peerId: string): void {
const audio = this.remoteAudioElements.get(peerId); const audio = this.remoteAudioElements.get(peerId);
if (audio) { if (audio) {
audio.srcObject = null; audio.srcObject = null;
audio.remove(); audio.remove();

View File

@@ -8,7 +8,7 @@
<span class="text-xs text-destructive">{{ <span class="text-xs text-destructive">{{
connectionErrorMessage() || 'Connection error' connectionErrorMessage() || 'Connection error'
}}</span> }}</span>
<button (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline"> <button type="button" (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
Retry Retry
</button> </button>
</div> </div>
@@ -31,7 +31,7 @@
} }
</p> </p>
</div> </div>
<button (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors"> <button type="button" (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors">
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" /> <ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" />
</button> </button>
</div> </div>
@@ -40,7 +40,7 @@
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
@if (isConnected()) { @if (isConnected()) {
<!-- Mute Toggle --> <!-- Mute Toggle -->
<button (click)="toggleMute()" [class]="getMuteButtonClass()"> <button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()">
@if (isMuted()) { @if (isMuted()) {
<ng-icon name="lucideMicOff" class="w-5 h-5" /> <ng-icon name="lucideMicOff" class="w-5 h-5" />
} @else { } @else {
@@ -49,12 +49,12 @@
</button> </button>
<!-- Deafen Toggle --> <!-- Deafen Toggle -->
<button (click)="toggleDeafen()" [class]="getDeafenButtonClass()"> <button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
<ng-icon name="lucideHeadphones" class="w-5 h-5" /> <ng-icon name="lucideHeadphones" class="w-5 h-5" />
</button> </button>
<!-- Screen Share Toggle --> <!-- Screen Share Toggle -->
<button (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()"> <button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
@if (isScreenSharing()) { @if (isScreenSharing()) {
<ng-icon name="lucideMonitorOff" class="w-5 h-5" /> <ng-icon name="lucideMonitorOff" class="w-5 h-5" />
} @else { } @else {
@@ -64,6 +64,7 @@
<!-- Disconnect --> <!-- Disconnect -->
<button <button
type="button"
(click)="disconnect()" (click)="disconnect()"
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors" class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
> >

View File

@@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { import {
Component, Component,
inject, inject,
signal, signal,
OnInit, OnInit,
OnDestroy, OnDestroy,
ElementRef, computed
ViewChild,
computed,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -21,7 +20,7 @@ import {
lucideMonitorOff, lucideMonitorOff,
lucidePhoneOff, lucidePhoneOff,
lucideSettings, lucideSettings,
lucideHeadphones, lucideHeadphones
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
@@ -55,10 +54,10 @@ interface AudioDevice {
lucideMonitorOff, lucideMonitorOff,
lucidePhoneOff, lucidePhoneOff,
lucideSettings, lucideSettings,
lucideHeadphones, lucideHeadphones
}), })
], ],
templateUrl: './voice-controls.component.html', templateUrl: './voice-controls.component.html'
}) })
export class VoiceControlsComponent implements OnInit, OnDestroy { export class VoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService); private webrtcService = inject(WebRTCService);
@@ -98,7 +97,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
return { return {
isConnected: this.isConnected(), isConnected: this.isConnected(),
outputVolume: this.outputVolume() / 100, outputVolume: this.outputVolume() / 100,
isDeafened: this.isDeafened(), isDeafened: this.isDeafened()
}; };
} }
@@ -115,18 +114,19 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe( this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
({ peerId, stream }) => { ({ peerId, stream }) => {
this.voicePlayback.handleRemoteStream(peerId, stream, this.playbackOptions()); this.voicePlayback.handleRemoteStream(peerId, stream, this.playbackOptions());
}, }
); );
// Listen for live voice-leveling toggle changes so we can // Listen for live voice-leveling toggle changes so we can
// rebuild all remote Audio elements immediately (no reconnect). // rebuild all remote Audio elements immediately (no reconnect).
this.voiceLevelingUnsubscribe = this.voiceLeveling.onEnabledChange( this.voiceLevelingUnsubscribe = this.voiceLeveling.onEnabledChange(
(enabled) => this.voicePlayback.rebuildAllRemoteAudio(enabled, this.playbackOptions()), (enabled) => this.voicePlayback.rebuildAllRemoteAudio(enabled, this.playbackOptions())
); );
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up // Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => { this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
const options = this.playbackOptions(); const options = this.playbackOptions();
this.voicePlayback.playPendingStreams(options); this.voicePlayback.playPendingStreams(options);
// Also ensure all remote streams from connected peers are playing // Also ensure all remote streams from connected peers are playing
// This handles the case where streams were received while voice was "connected" // This handles the case where streams were received while voice was "connected"
@@ -158,24 +158,27 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
if (!navigator.mediaDevices?.enumerateDevices) { if (!navigator.mediaDevices?.enumerateDevices) {
return; return;
} }
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set( this.inputDevices.set(
devices devices
.filter((device) => device.kind === 'audioinput') .filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId, label: device.label })), .map((device) => ({ deviceId: device.deviceId, label: device.label }))
); );
this.outputDevices.set( this.outputDevices.set(
devices devices
.filter((device) => device.kind === 'audiooutput') .filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId, label: device.label })), .map((device) => ({ deviceId: device.deviceId, label: device.label }))
); );
} catch (error) {} } catch (_error) {}
} }
async connect(): Promise<void> { async connect(): Promise<void> {
try { try {
// Require signaling connectivity first // Require signaling connectivity first
const ok = await this.webrtcService.ensureSignalingConnected(); const ok = await this.webrtcService.ensureSignalingConnected();
if (!ok) { if (!ok) {
return; return;
} }
@@ -188,14 +191,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audio: { audio: {
deviceId: this.selectedInputDevice() || undefined, deviceId: this.selectedInputDevice() || undefined,
echoCancellation: true, echoCancellation: true,
noiseSuppression: true, noiseSuppression: true
}, }
}); });
await this.webrtcService.setLocalStream(stream); await this.webrtcService.setLocalStream(stream);
// Track local mic for voice-activity visualisation // Track local mic for voice-activity visualisation
const userId = this.currentUser()?.id; const userId = this.currentUser()?.id;
if (userId) { if (userId) {
this.voiceActivity.trackLocalMic(userId, stream); this.voiceActivity.trackLocalMic(userId, stream);
} }
@@ -204,6 +208,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const room = this.currentRoom(); const room = this.currentRoom();
const roomId = this.currentUser()?.voiceState?.roomId || room?.id; const roomId = this.currentUser()?.voiceState?.roomId || room?.id;
const serverId = room?.id; const serverId = room?.id;
this.webrtcService.startVoiceHeartbeat(roomId, serverId); this.webrtcService.startVoiceHeartbeat(roomId, serverId);
// Broadcast voice state to other users // Broadcast voice state to other users
@@ -216,8 +221,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isMuted: this.isMuted(), isMuted: this.isMuted(),
isDeafened: this.isDeafened(), isDeafened: this.isDeafened(),
roomId, roomId,
serverId, serverId
}, }
}); });
// Play any pending remote streams now that we're connected // Play any pending remote streams now that we're connected
@@ -225,7 +230,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Persist settings after successful connection // Persist settings after successful connection
this.saveSettings(); this.saveSettings();
} catch (error) {} } catch (_error) {}
} }
// Retry connection when there's a connection error // Retry connection when there's a connection error
@@ -248,8 +253,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isConnected: false, isConnected: false,
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
serverId: this.currentRoom()?.id, serverId: this.currentRoom()?.id
}, }
}); });
// Stop screen sharing if active // Stop screen sharing if active
@@ -259,6 +264,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Untrack local mic from voice-activity visualisation // Untrack local mic from voice-activity visualisation
const userId = this.currentUser()?.id; const userId = this.currentUser()?.id;
if (userId) { if (userId) {
this.voiceActivity.untrackLocalMic(userId); this.voiceActivity.untrackLocalMic(userId);
} }
@@ -271,6 +277,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.voicePlayback.teardownAll(); this.voicePlayback.teardownAll();
const user = this.currentUser(); const user = this.currentUser();
if (user?.id) { if (user?.id) {
this.store.dispatch( this.store.dispatch(
UsersActions.updateVoiceState({ UsersActions.updateVoiceState({
@@ -280,9 +287,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
roomId: undefined, roomId: undefined,
serverId: undefined, serverId: undefined
}, }
}), })
); );
} }
@@ -306,8 +313,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
voiceState: { voiceState: {
isConnected: this.isConnected(), isConnected: this.isConnected(),
isMuted: this.isMuted(), isMuted: this.isMuted(),
isDeafened: this.isDeafened(), isDeafened: this.isDeafened()
}, }
}); });
} }
@@ -331,8 +338,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
voiceState: { voiceState: {
isConnected: this.isConnected(), isConnected: this.isConnected(),
isMuted: this.isMuted(), isMuted: this.isMuted(),
isDeafened: this.isDeafened(), isDeafened: this.isDeafened()
}, }
}); });
} }
@@ -344,7 +351,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
try { try {
await this.webrtcService.startScreenShare(this.includeSystemAudio()); await this.webrtcService.startScreenShare(this.includeSystemAudio());
this.isScreenSharing.set(true); this.isScreenSharing.set(true);
} catch (error) {} } catch (_error) {}
} }
} }
@@ -358,17 +365,21 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
onInputDeviceChange(event: Event): void { onInputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedInputDevice.set(select.value); this.selectedInputDevice.set(select.value);
// Reconnect with new device if connected // Reconnect with new device if connected
if (this.isConnected()) { if (this.isConnected()) {
this.disconnect(); this.disconnect();
this.connect(); this.connect();
} }
this.saveSettings(); this.saveSettings();
} }
onOutputDeviceChange(event: Event): void { onOutputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value); this.selectedOutputDevice.set(select.value);
this.applyOutputDevice(); this.applyOutputDevice();
this.saveSettings(); this.saveSettings();
@@ -376,12 +387,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
onInputVolumeChange(event: Event): void { onInputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10)); this.inputVolume.set(parseInt(input.value, 10));
this.saveSettings(); this.saveSettings();
} }
onOutputVolumeChange(event: Event): void { onOutputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.outputVolume.set(parseInt(input.value, 10)); this.outputVolume.set(parseInt(input.value, 10));
this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
@@ -391,6 +404,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
onLatencyProfileChange(event: Event): void { onLatencyProfileChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
const profile = select.value as 'low' | 'balanced' | 'high'; const profile = select.value as 'low' | 'balanced' | 'high';
this.latencyProfile.set(profile); this.latencyProfile.set(profile);
this.webrtcService.setLatencyProfile(profile); this.webrtcService.setLatencyProfile(profile);
this.saveSettings(); this.saveSettings();
@@ -399,6 +413,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
onAudioBitrateChange(event: Event): void { onAudioBitrateChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const kbps = parseInt(input.value, 10); const kbps = parseInt(input.value, 10);
this.audioBitrate.set(kbps); this.audioBitrate.set(kbps);
this.webrtcService.setAudioBitrate(kbps); this.webrtcService.setAudioBitrate(kbps);
this.saveSettings(); this.saveSettings();
@@ -406,12 +421,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
onIncludeSystemAudioChange(event: Event): void { onIncludeSystemAudioChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.includeSystemAudio.set(!!input.checked); this.includeSystemAudio.set(!!input.checked);
this.saveSettings(); this.saveSettings();
} }
async onNoiseReductionChange(event: Event): Promise<void> { async onNoiseReductionChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.noiseReduction.set(!!input.checked); this.noiseReduction.set(!!input.checked);
await this.webrtcService.toggleNoiseReduction(this.noiseReduction()); await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
this.saveSettings(); this.saveSettings();
@@ -420,7 +437,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private loadSettings(): void { private loadSettings(): void {
try { try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS); const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (!raw) return;
if (!raw)
return;
const settings = JSON.parse(raw) as { const settings = JSON.parse(raw) as {
inputDevice?: string; inputDevice?: string;
outputDevice?: string; outputDevice?: string;
@@ -431,14 +451,28 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
includeSystemAudio?: boolean; includeSystemAudio?: boolean;
noiseReduction?: boolean; noiseReduction?: boolean;
}; };
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
if (settings.outputDevice) this.selectedOutputDevice.set(settings.outputDevice); if (settings.inputDevice)
if (typeof settings.inputVolume === 'number') this.inputVolume.set(settings.inputVolume); this.selectedInputDevice.set(settings.inputDevice);
if (typeof settings.outputVolume === 'number') this.outputVolume.set(settings.outputVolume);
if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate); if (settings.outputDevice)
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile); this.selectedOutputDevice.set(settings.outputDevice);
if (typeof settings.inputVolume === 'number')
this.inputVolume.set(settings.inputVolume);
if (typeof settings.outputVolume === 'number')
this.outputVolume.set(settings.outputVolume);
if (typeof settings.audioBitrate === 'number')
this.audioBitrate.set(settings.audioBitrate);
if (settings.latencyProfile)
this.latencyProfile.set(settings.latencyProfile);
if (typeof settings.includeSystemAudio === 'boolean') if (typeof settings.includeSystemAudio === 'boolean')
this.includeSystemAudio.set(settings.includeSystemAudio); this.includeSystemAudio.set(settings.includeSystemAudio);
if (typeof settings.noiseReduction === 'boolean') if (typeof settings.noiseReduction === 'boolean')
this.noiseReduction.set(settings.noiseReduction); this.noiseReduction.set(settings.noiseReduction);
} catch {} } catch {}
@@ -454,8 +488,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audioBitrate: this.audioBitrate(), audioBitrate: this.audioBitrate(),
latencyProfile: this.latencyProfile(), latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(), includeSystemAudio: this.includeSystemAudio(),
noiseReduction: this.noiseReduction(), noiseReduction: this.noiseReduction()
}; };
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings)); localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
} catch {} } catch {}
} }
@@ -474,34 +509,43 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private async applyOutputDevice(): Promise<void> { private async applyOutputDevice(): Promise<void> {
const deviceId = this.selectedOutputDevice(); const deviceId = this.selectedOutputDevice();
if (!deviceId) return;
if (!deviceId)
return;
this.voicePlayback.applyOutputDevice(deviceId); this.voicePlayback.applyOutputDevice(deviceId);
} }
getMuteButtonClass(): string { getMuteButtonClass(): string {
const base = const base =
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
if (this.isMuted()) { if (this.isMuted()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
} }
return `${base} bg-secondary text-foreground hover:bg-secondary/80`; return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
} }
getDeafenButtonClass(): string { getDeafenButtonClass(): string {
const base = const base =
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
if (this.isDeafened()) { if (this.isDeafened()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
} }
return `${base} bg-secondary text-foreground hover:bg-secondary/80`; return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
} }
getScreenShareButtonClass(): string { getScreenShareButtonClass(): string {
const base = const base =
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
if (this.isScreenSharing()) { if (this.isScreenSharing()) {
return `${base} bg-primary/20 text-primary hover:bg-primary/30`; return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
} }
return `${base} bg-secondary text-foreground hover:bg-secondary/80`; return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
} }
} }

View File

@@ -23,7 +23,15 @@ import { Component, input, output, HostListener } from '@angular/core';
standalone: true, standalone: true,
template: ` template: `
<!-- Backdrop --> <!-- Backdrop -->
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelled.emit()"></div> <div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<!-- Dialog --> <!-- Dialog -->
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg" <div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
[class]="widthClass()"> [class]="widthClass()">
@@ -35,13 +43,15 @@ import { Component, input, output, HostListener } from '@angular/core';
</div> </div>
<div class="flex gap-2 p-3 border-t border-border"> <div class="flex gap-2 p-3 border-t border-border">
<button <button
(click)="cancelled.emit()" (click)="cancelled.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
> >
{{ cancelLabel() }} {{ cancelLabel() }}
</button> </button>
<button <button
(click)="confirmed.emit()" (click)="confirmed.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm" class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
[class.bg-primary]="variant() === 'primary'" [class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'" [class.text-primary-foreground]="variant() === 'primary'"
@@ -55,7 +65,7 @@ import { Component, input, output, HostListener } from '@angular/core';
</div> </div>
</div> </div>
`, `,
styles: [`:host { display: contents; }`], styles: [':host { display: contents; }']
}) })
export class ConfirmDialogComponent { export class ConfirmDialogComponent {
/** Dialog title. */ /** Dialog title. */
@@ -69,12 +79,12 @@ export class ConfirmDialogComponent {
/** Tailwind width class for the dialog. */ /** Tailwind width class for the dialog. */
widthClass = input<string>('w-[320px]'); widthClass = input<string>('w-[320px]');
/** Emitted when the user confirms. */ /** Emitted when the user confirms. */
confirmed = output<void>(); confirmed = output<undefined>();
/** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */ /** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */
cancelled = output<void>(); cancelled = output<undefined>();
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
onEscape(): void { onEscape(): void {
this.cancelled.emit(); this.cancelled.emit(undefined);
} }
} }

View File

@@ -22,7 +22,15 @@ import { Component, input, output, HostListener } from '@angular/core';
standalone: true, standalone: true,
template: ` template: `
<!-- Invisible backdrop that captures clicks outside --> <!-- Invisible backdrop that captures clicks outside -->
<div class="fixed inset-0 z-40" (click)="closed.emit()"></div> <div
class="fixed inset-0 z-40"
(click)="closed.emit(undefined)"
(keydown.enter)="closed.emit(undefined)"
(keydown.space)="closed.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<!-- Positioned menu panel --> <!-- Positioned menu panel -->
<div <div
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1" class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
@@ -57,21 +65,23 @@ import { Component, input, output, HostListener } from '@angular/core';
:host ::ng-deep .context-menu-empty { :host ::ng-deep .context-menu-empty {
@apply px-3 py-1.5 text-sm text-muted-foreground; @apply px-3 py-1.5 text-sm text-muted-foreground;
} }
`, `
], ]
}) })
export class ContextMenuComponent { export class ContextMenuComponent {
/** Horizontal position (px from left). */ /** Horizontal position (px from left). */
// eslint-disable-next-line id-length, id-denylist
x = input.required<number>(); x = input.required<number>();
/** Vertical position (px from top). */ /** Vertical position (px from top). */
// eslint-disable-next-line id-length, id-denylist
y = input.required<number>(); y = input.required<number>();
/** Tailwind width class for the panel (default `w-48`). */ /** Tailwind width class for the panel (default `w-48`). */
width = input<string>('w-48'); width = input<string>('w-48');
/** Emitted when the menu should close (backdrop click or Escape). */ /** Emitted when the menu should close (backdrop click or Escape). */
closed = output<void>(); closed = output<undefined>();
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
onEscape(): void { onEscape(): void {
this.closed.emit(); this.closed.emit(undefined);
} }
} }

View File

@@ -34,7 +34,7 @@ import { Component, input } from '@angular/core';
</div> </div>
} }
`, `,
styles: [`:host { display: contents; }`], styles: [':host { display: contents; }']
}) })
export class UserAvatarComponent { export class UserAvatarComponent {
/** Display name — first character is used as fallback initial. */ /** Display name — first character is used as fallback initial. */
@@ -48,7 +48,8 @@ export class UserAvatarComponent {
/** Compute the first-letter initial. */ /** Compute the first-letter initial. */
initial(): string { initial(): string {
return this.name()?.charAt(0)?.toUpperCase() ?? '?'; return this.name()?.charAt(0)
?.toUpperCase() ?? '?';
} }
/** Map size token to Tailwind dimension classes. */ /** Map size token to Tailwind dimension classes. */

View File

@@ -26,7 +26,7 @@ export interface AppState {
export const reducers: ActionReducerMap<AppState> = { export const reducers: ActionReducerMap<AppState> = {
messages: messagesReducer, messages: messagesReducer,
users: usersReducer, users: usersReducer,
rooms: roomsReducer, rooms: roomsReducer
}; };
/** Meta-reducers (e.g. logging) enabled only in development builds. */ /** Meta-reducers (e.g. logging) enabled only in development builds. */
@@ -44,7 +44,7 @@ export {
selectCurrentRoomMessages, selectCurrentRoomMessages,
selectMessageById, selectMessageById,
selectMessagesLoading, selectMessagesLoading,
selectCurrentRoomId as selectMessagesCurrentRoomId, selectCurrentRoomId as selectMessagesCurrentRoomId
} from './messages/messages.selectors'; } from './messages/messages.selectors';
export { export {
@@ -56,7 +56,7 @@ export {
selectOnlineUsers, selectOnlineUsers,
selectHostId, selectHostId,
selectIsCurrentUserHost as selectIsCurrentUserHostFromUsers, selectIsCurrentUserHost as selectIsCurrentUserHostFromUsers,
selectBannedUsers, selectBannedUsers
} from './users/users.selectors'; } from './users/users.selectors';
export { export {
@@ -66,7 +66,7 @@ export {
selectRoomSettings, selectRoomSettings,
selectIsCurrentUserHost, selectIsCurrentUserHost,
selectSavedRooms, selectSavedRooms,
selectRoomsLoading, selectRoomsLoading
} from './rooms/rooms.selectors'; } from './rooms/rooms.selectors';
// Re-export effects // Re-export effects

View File

@@ -26,7 +26,7 @@ import {
buildLocalInventoryMap, buildLocalInventoryMap,
findMissingIds, findMissingIds,
hydrateMessage, hydrateMessage,
mergeIncomingMessage, mergeIncomingMessage
} from './messages.helpers'; } from './messages.helpers';
/** Shared context injected into each handler function. */ /** Shared context injected into each handler function. */
@@ -50,18 +50,21 @@ type MessageHandler = (
*/ */
function handleInventoryRequest( function handleInventoryRequest(
event: any, event: any,
{ db, webrtc }: IncomingMessageContext, { db, webrtc }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { roomId, fromPeerId } = event; const { roomId, fromPeerId } = event;
if (!roomId || !fromPeerId) return EMPTY;
if (!roomId || !fromPeerId)
return EMPTY;
return from( return from(
(async () => { (async () => {
const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0); const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
const items = await Promise.all( const items = await Promise.all(
messages.map((msg) => buildInventoryItem(msg, db)), messages.map((msg) => buildInventoryItem(msg, db))
); );
items.sort((a, b) => a.ts - b.ts);
items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts);
for (const chunk of chunkArray(items, CHUNK_SIZE)) { for (const chunk of chunkArray(items, CHUNK_SIZE)) {
webrtc.sendToPeer(fromPeerId, { webrtc.sendToPeer(fromPeerId, {
@@ -69,10 +72,10 @@ function handleInventoryRequest(
roomId, roomId,
items: chunk, items: chunk,
total: items.length, total: items.length,
index: 0, index: 0
} as any); } as any);
} }
})(), })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
} }
@@ -82,10 +85,12 @@ function handleInventoryRequest(
*/ */
function handleInventory( function handleInventory(
event: any, event: any,
{ db, webrtc }: IncomingMessageContext, { db, webrtc }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { roomId, fromPeerId, items } = event; const { roomId, fromPeerId, items } = event;
if (!roomId || !Array.isArray(items) || !fromPeerId) return EMPTY;
if (!roomId || !Array.isArray(items) || !fromPeerId)
return EMPTY;
return from( return from(
(async () => { (async () => {
@@ -97,10 +102,10 @@ function handleInventory(
webrtc.sendToPeer(fromPeerId, { webrtc.sendToPeer(fromPeerId, {
type: 'chat-sync-request-ids', type: 'chat-sync-request-ids',
roomId, roomId,
ids: chunk, ids: chunk
} as any); } as any);
} }
})(), })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
} }
@@ -110,33 +115,36 @@ function handleInventory(
*/ */
function handleSyncRequestIds( function handleSyncRequestIds(
event: any, event: any,
{ db, webrtc, attachments }: IncomingMessageContext, { db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const { roomId, ids, fromPeerId } = event; const { roomId, ids, fromPeerId } = event;
if (!Array.isArray(ids) || !fromPeerId) return EMPTY;
if (!Array.isArray(ids) || !fromPeerId)
return EMPTY;
return from( return from(
(async () => { (async () => {
const maybeMessages = await Promise.all( const maybeMessages = await Promise.all(
(ids as string[]).map((id) => db.getMessageById(id)), (ids as string[]).map((id) => db.getMessageById(id))
); );
const messages = maybeMessages.filter( const messages = maybeMessages.filter(
(msg): msg is Message => !!msg, (msg): msg is Message => !!msg
); );
const hydrated = await Promise.all( const hydrated = await Promise.all(
messages.map((msg) => hydrateMessage(msg, db)), messages.map((msg) => hydrateMessage(msg, db))
); );
const msgIds = hydrated.map((msg) => msg.id); const msgIds = hydrated.map((msg) => msg.id);
const attachmentMetas = const attachmentMetas =
attachments.getAttachmentMetasForMessages(msgIds); attachments.getAttachmentMetasForMessages(msgIds);
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) { for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
const chunkAttachments: Record<string, any> = {}; const chunkAttachments: Record<string, any> = {};
for (const m of chunk) {
if (attachmentMetas[m.id]) for (const hydratedMessage of chunk) {
chunkAttachments[m.id] = attachmentMetas[m.id]; if (attachmentMetas[hydratedMessage.id])
chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id];
} }
webrtc.sendToPeer(fromPeerId, { webrtc.sendToPeer(fromPeerId, {
type: 'chat-sync-batch', type: 'chat-sync-batch',
roomId: roomId || '', roomId: roomId || '',
@@ -144,10 +152,10 @@ function handleSyncRequestIds(
attachments: attachments:
Object.keys(chunkAttachments).length > 0 Object.keys(chunkAttachments).length > 0
? chunkAttachments ? chunkAttachments
: undefined, : undefined
} as any); } as any);
} }
})(), })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
} }
@@ -158,9 +166,10 @@ function handleSyncRequestIds(
*/ */
function handleSyncBatch( function handleSyncBatch(
event: any, event: any,
{ db, attachments }: IncomingMessageContext, { db, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!Array.isArray(event.messages)) return EMPTY; if (!Array.isArray(event.messages))
return EMPTY;
if (event.attachments && typeof event.attachments === 'object') { if (event.attachments && typeof event.attachments === 'object') {
attachments.registerSyncedAttachments(event.attachments); attachments.registerSyncedAttachments(event.attachments);
@@ -170,8 +179,8 @@ function handleSyncBatch(
mergeMap((toUpsert) => mergeMap((toUpsert) =>
toUpsert.length > 0 toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert })) ? of(MessagesActions.syncMessages({ messages: toUpsert }))
: EMPTY, : EMPTY
), )
); );
} }
@@ -179,13 +188,15 @@ function handleSyncBatch(
async function processSyncBatch( async function processSyncBatch(
event: any, event: any,
db: DatabaseService, db: DatabaseService,
attachments: AttachmentService, attachments: AttachmentService
): Promise<Message[]> { ): Promise<Message[]> {
const toUpsert: Message[] = []; const toUpsert: Message[] = [];
for (const incoming of event.messages as Message[]) { for (const incoming of event.messages as Message[]) {
const { message, changed } = await mergeIncomingMessage(incoming, db); const { message, changed } = await mergeIncomingMessage(incoming, db);
if (changed) toUpsert.push(message);
if (changed)
toUpsert.push(message);
} }
if (event.attachments && event.fromPeerId) { if (event.attachments && event.fromPeerId) {
@@ -198,19 +209,22 @@ async function processSyncBatch(
/** Auto-requests any unavailable image attachments from any connected peer. */ /** Auto-requests any unavailable image attachments from any connected peer. */
function requestMissingImages( function requestMissingImages(
attachmentMap: Record<string, any[]>, attachmentMap: Record<string, any[]>,
attachments: AttachmentService, attachments: AttachmentService
): void { ): void {
for (const [msgId, metas] of Object.entries(attachmentMap)) { for (const [msgId, metas] of Object.entries(attachmentMap)) {
for (const meta of metas) { for (const meta of metas) {
if (!meta.isImage) continue; if (!meta.isImage)
continue;
const atts = attachments.getForMessage(msgId); const atts = attachments.getForMessage(msgId);
const att = atts.find((a: any) => a.id === meta.id); const matchingAttachment = atts.find((attachment: any) => attachment.id === meta.id);
if ( if (
att && matchingAttachment &&
!att.available && !matchingAttachment.available &&
!(att.receivedBytes && att.receivedBytes > 0) !(matchingAttachment.receivedBytes && matchingAttachment.receivedBytes > 0)
) { ) {
attachments.requestImageFromAnyPeer(msgId, att); attachments.requestImageFromAnyPeer(msgId, matchingAttachment);
} }
} }
} }
@@ -219,16 +233,20 @@ function requestMissingImages(
/** Saves an incoming chat message to DB and dispatches receiveMessage. */ /** Saves an incoming chat message to DB and dispatches receiveMessage. */
function handleChatMessage( function handleChatMessage(
event: any, event: any,
{ db, currentUser }: IncomingMessageContext, { db, currentUser }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const msg = event.message; const msg = event.message;
if (!msg) return EMPTY;
if (!msg)
return EMPTY;
// Skip our own messages (reflected via server relay) // Skip our own messages (reflected via server relay)
const isOwnMessage = const isOwnMessage =
msg.senderId === currentUser?.id || msg.senderId === currentUser?.id ||
msg.senderId === currentUser?.oderId; msg.senderId === currentUser?.oderId;
if (isOwnMessage) return EMPTY;
if (isOwnMessage)
return EMPTY;
db.saveMessage(msg); db.saveMessage(msg);
return of(MessagesActions.receiveMessage({ message: msg })); return of(MessagesActions.receiveMessage({ message: msg }));
@@ -237,42 +255,45 @@ function handleChatMessage(
/** Applies a remote message edit to the local DB and store. */ /** Applies a remote message edit to the local DB and store. */
function handleMessageEdited( function handleMessageEdited(
event: any, event: any,
{ db }: IncomingMessageContext, { db }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId || !event.content) return EMPTY; if (!event.messageId || !event.content)
return EMPTY;
db.updateMessage(event.messageId, { db.updateMessage(event.messageId, {
content: event.content, content: event.content,
editedAt: event.editedAt, editedAt: event.editedAt
}); });
return of( return of(
MessagesActions.editMessageSuccess({ MessagesActions.editMessageSuccess({
messageId: event.messageId, messageId: event.messageId,
content: event.content, content: event.content,
editedAt: event.editedAt, editedAt: event.editedAt
}), })
); );
} }
/** Applies a remote message deletion to the local DB and store. */ /** Applies a remote message deletion to the local DB and store. */
function handleMessageDeleted( function handleMessageDeleted(
event: any, event: any,
{ db }: IncomingMessageContext, { db }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId) return EMPTY; if (!event.messageId)
return EMPTY;
db.deleteMessage(event.messageId); db.deleteMessage(event.messageId);
return of( return of(
MessagesActions.deleteMessageSuccess({ messageId: event.messageId }), MessagesActions.deleteMessageSuccess({ messageId: event.messageId })
); );
} }
/** Saves an incoming reaction to DB and updates the store. */ /** Saves an incoming reaction to DB and updates the store. */
function handleReactionAdded( function handleReactionAdded(
event: any, event: any,
{ db }: IncomingMessageContext, { db }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId || !event.reaction) return EMPTY; if (!event.messageId || !event.reaction)
return EMPTY;
db.saveReaction(event.reaction); db.saveReaction(event.reaction);
return of(MessagesActions.addReactionSuccess({ reaction: event.reaction })); return of(MessagesActions.addReactionSuccess({ reaction: event.reaction }));
@@ -281,23 +302,24 @@ function handleReactionAdded(
/** Removes a reaction from DB and updates the store. */ /** Removes a reaction from DB and updates the store. */
function handleReactionRemoved( function handleReactionRemoved(
event: any, event: any,
{ db }: IncomingMessageContext, { db }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId || !event.oderId || !event.emoji) return EMPTY; if (!event.messageId || !event.oderId || !event.emoji)
return EMPTY;
db.removeReaction(event.messageId, event.oderId, event.emoji); db.removeReaction(event.messageId, event.oderId, event.emoji);
return of( return of(
MessagesActions.removeReactionSuccess({ MessagesActions.removeReactionSuccess({
messageId: event.messageId, messageId: event.messageId,
oderId: event.oderId, oderId: event.oderId,
emoji: event.emoji, emoji: event.emoji
}), })
); );
} }
function handleFileAnnounce( function handleFileAnnounce(
event: any, event: any,
{ attachments }: IncomingMessageContext, { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileAnnounce(event); attachments.handleFileAnnounce(event);
return EMPTY; return EMPTY;
@@ -305,7 +327,7 @@ function handleFileAnnounce(
function handleFileChunk( function handleFileChunk(
event: any, event: any,
{ attachments }: IncomingMessageContext, { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileChunk(event); attachments.handleFileChunk(event);
return EMPTY; return EMPTY;
@@ -313,7 +335,7 @@ function handleFileChunk(
function handleFileRequest( function handleFileRequest(
event: any, event: any,
{ attachments }: IncomingMessageContext, { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileRequest(event); attachments.handleFileRequest(event);
return EMPTY; return EMPTY;
@@ -321,7 +343,7 @@ function handleFileRequest(
function handleFileCancel( function handleFileCancel(
event: any, event: any,
{ attachments }: IncomingMessageContext, { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileCancel(event); attachments.handleFileCancel(event);
return EMPTY; return EMPTY;
@@ -329,7 +351,7 @@ function handleFileCancel(
function handleFileNotFound( function handleFileNotFound(
event: any, event: any,
{ attachments }: IncomingMessageContext, { attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
attachments.handleFileNotFound(event); attachments.handleFileNotFound(event);
return EMPTY; return EMPTY;
@@ -341,21 +363,21 @@ function handleFileNotFound(
*/ */
function handleSyncSummary( function handleSyncSummary(
event: any, event: any,
{ db, webrtc, currentRoom }: IncomingMessageContext, { db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!currentRoom) return EMPTY; if (!currentRoom)
return EMPTY;
return from( return from(
(async () => { (async () => {
const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0); const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
const localCount = local.length; const localCount = local.length;
const localLastUpdated = local.reduce( const localLastUpdated = local.reduce(
(max, m) => Math.max(max, m.editedAt || m.timestamp || 0), (maxTimestamp, message) => Math.max(maxTimestamp, message.editedAt || message.timestamp || 0),
0, 0
); );
const remoteLastUpdated = event.lastUpdated || 0; const remoteLastUpdated = event.lastUpdated || 0;
const remoteCount = event.count || 0; const remoteCount = event.count || 0;
const identical = const identical =
localLastUpdated === remoteLastUpdated && localCount === remoteCount; localLastUpdated === remoteLastUpdated && localCount === remoteCount;
const needsSync = const needsSync =
@@ -365,38 +387,41 @@ function handleSyncSummary(
if (!identical && needsSync && event.fromPeerId) { if (!identical && needsSync && event.fromPeerId) {
webrtc.sendToPeer(event.fromPeerId, { webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-sync-request', type: 'chat-sync-request',
roomId: currentRoom.id, roomId: currentRoom.id
} as any); } as any);
} }
})(), })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
} }
/** Responds to a peer's full sync request by sending all local messages. */ /** Responds to a peer's full sync request by sending all local messages. */
function handleSyncRequest( function handleSyncRequest(
event: any, event: any,
{ db, webrtc, currentRoom }: IncomingMessageContext, { db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!currentRoom || !event.fromPeerId) return EMPTY; if (!currentRoom || !event.fromPeerId)
return EMPTY;
return from( return from(
(async () => { (async () => {
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0); const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
webrtc.sendToPeer(event.fromPeerId, { webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-sync-full', type: 'chat-sync-full',
roomId: currentRoom.id, roomId: currentRoom.id,
messages: all, messages: all
} as any); } as any);
})(), })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
} }
/** Merges a full message dump from a peer into the local DB and store. */ /** Merges a full message dump from a peer into the local DB and store. */
function handleSyncFull( function handleSyncFull(
event: any, event: any,
{ db }: IncomingMessageContext, { db }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messages || !Array.isArray(event.messages)) return EMPTY; if (!event.messages || !Array.isArray(event.messages))
return EMPTY;
event.messages.forEach((msg: Message) => db.saveMessage(msg)); event.messages.forEach((msg: Message) => db.saveMessage(msg));
return of(MessagesActions.syncMessages({ messages: event.messages })); return of(MessagesActions.syncMessages({ messages: event.messages }));
@@ -429,7 +454,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
// Legacy sync handshake // Legacy sync handshake
'chat-sync-summary': handleSyncSummary, 'chat-sync-summary': handleSyncSummary,
'chat-sync-request': handleSyncRequest, 'chat-sync-request': handleSyncRequest,
'chat-sync-full': handleSyncFull, 'chat-sync-full': handleSyncFull
}; };
/** /**
@@ -438,8 +463,9 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
*/ */
export function dispatchIncomingMessage( export function dispatchIncomingMessage(
event: any, event: any,
ctx: IncomingMessageContext, ctx: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
const handler = HANDLER_MAP[event.type]; const handler = HANDLER_MAP[event.type];
return handler ? handler(event, ctx) : EMPTY; return handler ? handler(event, ctx) : EMPTY;
} }

View File

@@ -8,6 +8,7 @@
* Extracted from the monolithic MessagesEffects to keep each * Extracted from the monolithic MessagesEffects to keep each
* class focused on a single concern. * class focused on a single concern.
*/ */
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -22,7 +23,7 @@ import {
exhaustMap, exhaustMap,
switchMap, switchMap,
repeat, repeat,
takeUntil, takeUntil
} from 'rxjs/operators'; } from 'rxjs/operators';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
import { RoomsActions } from '../rooms/rooms.actions'; import { RoomsActions } from '../rooms/rooms.actions';
@@ -36,7 +37,7 @@ import {
SYNC_POLL_FAST_MS, SYNC_POLL_FAST_MS,
SYNC_POLL_SLOW_MS, SYNC_POLL_SLOW_MS,
SYNC_TIMEOUT_MS, SYNC_TIMEOUT_MS,
getLatestTimestamp, getLatestTimestamp
} from './messages.helpers'; } from './messages.helpers';
@Injectable() @Injectable()
@@ -61,28 +62,31 @@ export class MessagesSyncEffects {
this.webrtc.onPeerConnected.pipe( this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([peerId, room]) => { mergeMap(([peerId, room]) => {
if (!room) return EMPTY; if (!room)
return EMPTY;
return from( return from(
this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0), this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0)
).pipe( ).pipe(
tap((messages) => { tap((messages) => {
const count = messages.length; const count = messages.length;
const lastUpdated = getLatestTimestamp(messages); const lastUpdated = getLatestTimestamp(messages);
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
type: 'chat-sync-summary', type: 'chat-sync-summary',
roomId: room.id, roomId: room.id,
count, count,
lastUpdated, lastUpdated
} as any); } as any);
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: room.id, roomId: room.id
} as any); } as any);
}), })
); );
}), })
), ),
{ dispatch: false }, { dispatch: false }
); );
/** /**
@@ -96,35 +100,38 @@ export class MessagesSyncEffects {
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ room }, currentRoom]) => { mergeMap(([{ room }, currentRoom]) => {
const activeRoom = currentRoom || room; const activeRoom = currentRoom || room;
if (!activeRoom) return EMPTY;
if (!activeRoom)
return EMPTY;
return from( return from(
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0), this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)
).pipe( ).pipe(
tap((messages) => { tap((messages) => {
const count = messages.length; const count = messages.length;
const lastUpdated = getLatestTimestamp(messages); const lastUpdated = getLatestTimestamp(messages);
for (const pid of this.webrtc.getConnectedPeers()) { for (const pid of this.webrtc.getConnectedPeers()) {
try { try {
this.webrtc.sendToPeer(pid, { this.webrtc.sendToPeer(pid, {
type: 'chat-sync-summary', type: 'chat-sync-summary',
roomId: activeRoom.id, roomId: activeRoom.id,
count, count,
lastUpdated, lastUpdated
} as any); } as any);
this.webrtc.sendToPeer(pid, { this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: activeRoom.id, roomId: activeRoom.id
} as any); } as any);
} catch { } catch {
/* peer may have disconnected */ /* peer may have disconnected */
} }
} }
}), })
); );
}), })
), ),
{ dispatch: false }, { dispatch: false }
); );
/** /**
@@ -136,44 +143,46 @@ export class MessagesSyncEffects {
repeat({ repeat({
delay: () => delay: () =>
timer( timer(
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS, this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
), )
}), }),
takeUntil(this.syncReset$), takeUntil(this.syncReset$),
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
filter( filter(
([, room]) => ([, room]) =>
!!room && this.webrtc.getConnectedPeers().length > 0, !!room && this.webrtc.getConnectedPeers().length > 0
), ),
exhaustMap(([, room]) => { exhaustMap(([, room]) => {
const peers = this.webrtc.getConnectedPeers(); const peers = this.webrtc.getConnectedPeers();
if (!room || peers.length === 0) { if (!room || peers.length === 0) {
return of(MessagesActions.syncComplete()); return of(MessagesActions.syncComplete());
} }
return from( return from(
this.db.getMessages(room.id, INVENTORY_LIMIT, 0), this.db.getMessages(room.id, INVENTORY_LIMIT, 0)
).pipe( ).pipe(
map(() => { map(() => {
for (const pid of peers) { for (const pid of peers) {
try { try {
this.webrtc.sendToPeer(pid, { this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: room.id, roomId: room.id
} as any); } as any);
} catch { } catch {
/* peer may have disconnected */ /* peer may have disconnected */
} }
} }
return MessagesActions.startSync(); return MessagesActions.startSync();
}), }),
catchError(() => { catchError(() => {
this.lastSyncClean = false; this.lastSyncClean = false;
return of(MessagesActions.syncComplete()); return of(MessagesActions.syncComplete());
}), })
); );
}), })
), )
); );
/** /**
@@ -184,15 +193,15 @@ export class MessagesSyncEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(MessagesActions.startSync), ofType(MessagesActions.startSync),
switchMap(() => from( switchMap(() => from(
new Promise<void>((resolve) => setTimeout(resolve, SYNC_TIMEOUT_MS)), new Promise<void>((resolve) => setTimeout(resolve, SYNC_TIMEOUT_MS))
)), )),
withLatestFrom(this.store.select(selectMessagesSyncing)), withLatestFrom(this.store.select(selectMessagesSyncing)),
filter(([, syncing]) => syncing), filter(([, syncing]) => syncing),
map(() => { map(() => {
this.lastSyncClean = true; this.lastSyncClean = true;
return MessagesActions.syncComplete(); return MessagesActions.syncComplete();
}), })
), )
); );
/** /**
@@ -204,8 +213,8 @@ export class MessagesSyncEffects {
this.webrtc.onPeerConnected.pipe( this.webrtc.onPeerConnected.pipe(
tap(() => { tap(() => {
this.lastSyncClean = false; this.lastSyncClean = false;
}), })
), ),
{ dispatch: false }, { dispatch: false }
); );
} }

View File

@@ -46,6 +46,6 @@ export const MessagesActions = createActionGroup({
'Sync Complete': emptyProps(), 'Sync Complete': emptyProps(),
/** Removes all messages from the store (e.g. when leaving a room). */ /** Removes all messages from the store (e.g. when leaving a room). */
'Clear Messages': emptyProps(), 'Clear Messages': emptyProps()
}, }
}); });

View File

@@ -8,6 +8,7 @@
* The giant `incomingMessages$` switch-case has been replaced by a * The giant `incomingMessages$` switch-case has been replaced by a
* handler registry in `messages-incoming.handlers.ts`. * handler registry in `messages-incoming.handlers.ts`.
*/ */
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -25,7 +26,7 @@ import { Message, Reaction } from '../../core/models';
import { hydrateMessages } from './messages.helpers'; import { hydrateMessages } from './messages.helpers';
import { import {
dispatchIncomingMessage, dispatchIncomingMessage,
IncomingMessageContext, IncomingMessageContext
} from './messages-incoming.handlers'; } from './messages-incoming.handlers';
@Injectable() @Injectable()
@@ -45,14 +46,15 @@ export class MessagesEffects {
from(this.db.getMessages(roomId)).pipe( from(this.db.getMessages(roomId)).pipe(
mergeMap(async (messages) => { mergeMap(async (messages) => {
const hydrated = await hydrateMessages(messages, this.db); const hydrated = await hydrateMessages(messages, this.db);
return MessagesActions.loadMessagesSuccess({ messages: hydrated }); return MessagesActions.loadMessagesSuccess({ messages: hydrated });
}), }),
catchError((error) => catchError((error) =>
of(MessagesActions.loadMessagesFailure({ error: error.message })), of(MessagesActions.loadMessagesFailure({ error: error.message }))
), )
), )
), )
), )
); );
/** Constructs a new message, persists it locally, and broadcasts to all peers. */ /** Constructs a new message, persists it locally, and broadcasts to all peers. */
@@ -61,7 +63,7 @@ export class MessagesEffects {
ofType(MessagesActions.sendMessage), ofType(MessagesActions.sendMessage),
withLatestFrom( withLatestFrom(
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom), this.store.select(selectCurrentRoom)
), ),
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => { mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
if (!currentUser || !currentRoom) { if (!currentUser || !currentRoom) {
@@ -78,7 +80,7 @@ export class MessagesEffects {
timestamp: this.timeSync.now(), timestamp: this.timeSync.now(),
reactions: [], reactions: [],
isDeleted: false, isDeleted: false,
replyToId, replyToId
}; };
this.db.saveMessage(message); this.db.saveMessage(message);
@@ -87,9 +89,9 @@ export class MessagesEffects {
return of(MessagesActions.sendMessageSuccess({ message })); return of(MessagesActions.sendMessageSuccess({ message }));
}), }),
catchError((error) => catchError((error) =>
of(MessagesActions.sendMessageFailure({ error: error.message })), of(MessagesActions.sendMessageFailure({ error: error.message }))
), )
), )
); );
/** Edits an existing message (author-only), updates DB, and broadcasts the change. */ /** Edits an existing message (author-only), updates DB, and broadcasts the change. */
@@ -107,22 +109,24 @@ export class MessagesEffects {
if (!existing) { if (!existing) {
return of(MessagesActions.editMessageFailure({ error: 'Message not found' })); return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
} }
if (existing.senderId !== currentUser.id) { if (existing.senderId !== currentUser.id) {
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' })); return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
} }
const editedAt = this.timeSync.now(); const editedAt = this.timeSync.now();
this.db.updateMessage(messageId, { content, editedAt }); this.db.updateMessage(messageId, { content, editedAt });
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt }); this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt })); return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
}), }),
catchError((error) => catchError((error) =>
of(MessagesActions.editMessageFailure({ error: error.message })), of(MessagesActions.editMessageFailure({ error: error.message }))
), )
); );
}), })
), )
); );
/** Soft-deletes a message (author-only), marks it deleted in DB, and broadcasts. */ /** Soft-deletes a message (author-only), marks it deleted in DB, and broadcasts. */
@@ -140,6 +144,7 @@ export class MessagesEffects {
if (!existing) { if (!existing) {
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' })); return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
} }
if (existing.senderId !== currentUser.id) { if (existing.senderId !== currentUser.id) {
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' })); return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
} }
@@ -150,11 +155,11 @@ export class MessagesEffects {
return of(MessagesActions.deleteMessageSuccess({ messageId })); return of(MessagesActions.deleteMessageSuccess({ messageId }));
}), }),
catchError((error) => catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message })), of(MessagesActions.deleteMessageFailure({ error: error.message }))
), )
); );
}), })
), )
); );
/** Soft-deletes any message (admin+ only). */ /** Soft-deletes any message (admin+ only). */
@@ -182,9 +187,9 @@ export class MessagesEffects {
return of(MessagesActions.deleteMessageSuccess({ messageId })); return of(MessagesActions.deleteMessageSuccess({ messageId }));
}), }),
catchError((error) => catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message })), of(MessagesActions.deleteMessageFailure({ error: error.message }))
), )
), )
); );
/** Adds an emoji reaction to a message, persists it, and broadcasts to peers. */ /** Adds an emoji reaction to a message, persists it, and broadcasts to peers. */
@@ -193,7 +198,8 @@ export class MessagesEffects {
ofType(MessagesActions.addReaction), ofType(MessagesActions.addReaction),
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId, emoji }, currentUser]) => { mergeMap(([{ messageId, emoji }, currentUser]) => {
if (!currentUser) return EMPTY; if (!currentUser)
return EMPTY;
const reaction: Reaction = { const reaction: Reaction = {
id: uuidv4(), id: uuidv4(),
@@ -201,15 +207,15 @@ export class MessagesEffects {
oderId: currentUser.id, oderId: currentUser.id,
userId: currentUser.id, userId: currentUser.id,
emoji, emoji,
timestamp: this.timeSync.now(), timestamp: this.timeSync.now()
}; };
this.db.saveReaction(reaction); this.db.saveReaction(reaction);
this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction }); this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction });
return of(MessagesActions.addReactionSuccess({ reaction })); return of(MessagesActions.addReactionSuccess({ reaction }));
}), })
), )
); );
/** Removes the current user's reaction from a message, deletes from DB, and broadcasts. */ /** Removes the current user's reaction from a message, deletes from DB, and broadcasts. */
@@ -218,25 +224,26 @@ export class MessagesEffects {
ofType(MessagesActions.removeReaction), ofType(MessagesActions.removeReaction),
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId, emoji }, currentUser]) => { mergeMap(([{ messageId, emoji }, currentUser]) => {
if (!currentUser) return EMPTY; if (!currentUser)
return EMPTY;
this.db.removeReaction(messageId, currentUser.id, emoji); this.db.removeReaction(messageId, currentUser.id, emoji);
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'reaction-removed', type: 'reaction-removed',
messageId, messageId,
oderId: currentUser.id, oderId: currentUser.id,
emoji, emoji
}); });
return of( return of(
MessagesActions.removeReactionSuccess({ MessagesActions.removeReactionSuccess({
messageId, messageId,
oderId: currentUser.id, oderId: currentUser.id,
emoji, emoji
}), })
); );
}), })
), )
); );
/** /**
@@ -247,7 +254,7 @@ export class MessagesEffects {
this.webrtc.onMessageReceived.pipe( this.webrtc.onMessageReceived.pipe(
withLatestFrom( withLatestFrom(
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom), this.store.select(selectCurrentRoom)
), ),
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => { mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
const ctx: IncomingMessageContext = { const ctx: IncomingMessageContext = {
@@ -255,10 +262,11 @@ export class MessagesEffects {
webrtc: this.webrtc, webrtc: this.webrtc,
attachments: this.attachments, attachments: this.attachments,
currentUser, currentUser,
currentRoom, currentRoom
}; };
return dispatchIncomingMessage(event, ctx); return dispatchIncomingMessage(event, ctx);
}), })
), )
); );
} }

View File

@@ -34,32 +34,35 @@ export function getMessageTimestamp(msg: Message): number {
export function getLatestTimestamp(messages: Message[]): number { export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce( return messages.reduce(
(max, msg) => Math.max(max, getMessageTimestamp(msg)), (max, msg) => Math.max(max, getMessageTimestamp(msg)),
0, 0
); );
} }
/** Splits an array into chunks of the given size. */ /** Splits an array into chunks of the given size. */
export function chunkArray<T>(items: T[], size: number): T[][] { export function chunkArray<T>(items: T[], size: number): T[][] {
const chunks: T[][] = []; const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size)); for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
} }
return chunks; return chunks;
} }
/** Hydrates a single message with its reactions from the database. */ /** Hydrates a single message with its reactions from the database. */
export async function hydrateMessage( export async function hydrateMessage(
msg: Message, msg: Message,
db: DatabaseService, db: DatabaseService
): Promise<Message> { ): Promise<Message> {
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
return reactions.length > 0 ? { ...msg, reactions } : msg; return reactions.length > 0 ? { ...msg, reactions } : msg;
} }
/** Hydrates an array of messages with their reactions. */ /** Hydrates an array of messages with their reactions. */
export async function hydrateMessages( export async function hydrateMessages(
messages: Message[], messages: Message[],
db: DatabaseService, db: DatabaseService
): Promise<Message[]> { ): Promise<Message[]> {
return Promise.all(messages.map((msg) => hydrateMessage(msg, db))); return Promise.all(messages.map((msg) => hydrateMessage(msg, db)));
} }
@@ -74,35 +77,40 @@ export interface InventoryItem {
/** Builds a sync inventory item from a message and its reaction count. */ /** Builds a sync inventory item from a message and its reaction count. */
export async function buildInventoryItem( export async function buildInventoryItem(
msg: Message, msg: Message,
db: DatabaseService, db: DatabaseService
): Promise<InventoryItem> { ): Promise<InventoryItem> {
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
return { id: msg.id, ts: getMessageTimestamp(msg), rc: reactions.length }; return { id: msg.id, ts: getMessageTimestamp(msg), rc: reactions.length };
} }
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */ /** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
export async function buildLocalInventoryMap( export async function buildLocalInventoryMap(
messages: Message[], messages: Message[],
db: DatabaseService, db: DatabaseService
): Promise<Map<string, { ts: number; rc: number }>> { ): Promise<Map<string, { ts: number; rc: number }>> {
const map = new Map<string, { ts: number; rc: number }>(); const map = new Map<string, { ts: number; rc: number }>();
await Promise.all( await Promise.all(
messages.map(async (msg) => { messages.map(async (msg) => {
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
map.set(msg.id, { ts: getMessageTimestamp(msg), rc: reactions.length }); map.set(msg.id, { ts: getMessageTimestamp(msg), rc: reactions.length });
}), })
); );
return map; return map;
} }
/** Identifies missing or stale message IDs by comparing remote items against a local map. */ /** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds( export function findMissingIds(
remoteItems: ReadonlyArray<{ id: string; ts: number; rc?: number }>, remoteItems: readonly { id: string; ts: number; rc?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number }>, localMap: ReadonlyMap<string, { ts: number; rc: number }>
): string[] { ): string[] {
const missing: string[] = []; const missing: string[] = [];
for (const item of remoteItems) { for (const item of remoteItems) {
const local = localMap.get(item.id); const local = localMap.get(item.id);
if ( if (
!local || !local ||
item.ts > local.ts || item.ts > local.ts ||
@@ -111,6 +119,7 @@ export function findMissingIds(
missing.push(item.id); missing.push(item.id);
} }
} }
return missing; return missing;
} }
@@ -127,7 +136,7 @@ export interface MergeResult {
*/ */
export async function mergeIncomingMessage( export async function mergeIncomingMessage(
incoming: Message, incoming: Message,
db: DatabaseService, db: DatabaseService
): Promise<MergeResult> { ): Promise<MergeResult> {
const existing = await db.getMessageById(incoming.id); const existing = await db.getMessageById(incoming.id);
const existingTs = existing ? getMessageTimestamp(existing) : -1; const existingTs = existing ? getMessageTimestamp(existing) : -1;
@@ -140,17 +149,30 @@ export async function mergeIncomingMessage(
// Persist incoming reactions (deduped by the DB layer) // Persist incoming reactions (deduped by the DB layer)
const incomingReactions = incoming.reactions ?? []; const incomingReactions = incoming.reactions ?? [];
for (const reaction of incomingReactions) { for (const reaction of incomingReactions) {
await db.saveReaction(reaction); await db.saveReaction(reaction);
} }
const changed = isNewer || incomingReactions.length > 0; const changed = isNewer || incomingReactions.length > 0;
if (changed) { if (changed) {
const reactions = await db.getReactionsForMessage(incoming.id); const reactions = await db.getReactionsForMessage(incoming.id);
const baseMessage = isNewer ? incoming : existing;
if (!baseMessage) {
return { message: incoming, changed };
}
return { return {
message: { ...(isNewer ? incoming : existing!), reactions }, message: { ...baseMessage, reactions },
changed, changed
}; };
} }
return { message: existing!, changed: false };
if (!existing) {
return { message: incoming, changed: false };
}
return { message: existing, changed: false };
} }

View File

@@ -17,14 +17,14 @@ export interface MessagesState extends EntityState<Message> {
export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({ export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({
selectId: (message) => message.id, selectId: (message) => message.id,
sortComparer: (a, b) => a.timestamp - b.timestamp, sortComparer: (messageA, messageB) => messageA.timestamp - messageB.timestamp
}); });
export const initialState: MessagesState = messagesAdapter.getInitialState({ export const initialState: MessagesState = messagesAdapter.getInitialState({
loading: false, loading: false,
syncing: false, syncing: false,
error: null, error: null,
currentRoomId: null, currentRoomId: null
}); });
export const messagesReducer = createReducer( export const messagesReducer = createReducer(
@@ -37,47 +37,48 @@ export const messagesReducer = createReducer(
...state, ...state,
loading: true, loading: true,
error: null, error: null,
currentRoomId: roomId, currentRoomId: roomId
}); });
} }
return { return {
...state, ...state,
loading: true, loading: true,
error: null, error: null,
currentRoomId: roomId, currentRoomId: roomId
}; };
}), }),
on(MessagesActions.loadMessagesSuccess, (state, { messages }) => on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
messagesAdapter.setAll(messages, { messagesAdapter.setAll(messages, {
...state, ...state,
loading: false, loading: false
}) })
), ),
on(MessagesActions.loadMessagesFailure, (state, { error }) => ({ on(MessagesActions.loadMessagesFailure, (state, { error }) => ({
...state, ...state,
loading: false, loading: false,
error, error
})), })),
// Send message // Send message
on(MessagesActions.sendMessage, (state) => ({ on(MessagesActions.sendMessage, (state) => ({
...state, ...state,
loading: true, loading: true
})), })),
on(MessagesActions.sendMessageSuccess, (state, { message }) => on(MessagesActions.sendMessageSuccess, (state, { message }) =>
messagesAdapter.addOne(message, { messagesAdapter.addOne(message, {
...state, ...state,
loading: false, loading: false
}) })
), ),
on(MessagesActions.sendMessageFailure, (state, { error }) => ({ on(MessagesActions.sendMessageFailure, (state, { error }) => ({
...state, ...state,
loading: false, loading: false,
error, error
})), })),
// Receive message from peer // Receive message from peer
@@ -90,7 +91,7 @@ export const messagesReducer = createReducer(
messagesAdapter.updateOne( messagesAdapter.updateOne(
{ {
id: messageId, id: messageId,
changes: { content, editedAt }, changes: { content, editedAt }
}, },
state state
) )
@@ -101,7 +102,7 @@ export const messagesReducer = createReducer(
messagesAdapter.updateOne( messagesAdapter.updateOne(
{ {
id: messageId, id: messageId,
changes: { isDeleted: true, content: '[Message deleted]' }, changes: { isDeleted: true, content: '[Message deleted]' }
}, },
state state
) )
@@ -110,20 +111,23 @@ export const messagesReducer = createReducer(
// Add reaction // Add reaction
on(MessagesActions.addReactionSuccess, (state, { reaction }) => { on(MessagesActions.addReactionSuccess, (state, { reaction }) => {
const message = state.entities[reaction.messageId]; const message = state.entities[reaction.messageId];
if (!message) return state;
if (!message)
return state;
const existingReaction = message.reactions.find( const existingReaction = message.reactions.find(
(existing) => existing.emoji === reaction.emoji && existing.userId === reaction.userId (existing) => existing.emoji === reaction.emoji && existing.userId === reaction.userId
); );
if (existingReaction) return state; if (existingReaction)
return state;
return messagesAdapter.updateOne( return messagesAdapter.updateOne(
{ {
id: reaction.messageId, id: reaction.messageId,
changes: { changes: {
reactions: [...message.reactions, reaction], reactions: [...message.reactions, reaction]
}, }
}, },
state state
); );
@@ -132,7 +136,9 @@ export const messagesReducer = createReducer(
// Remove reaction // Remove reaction
on(MessagesActions.removeReactionSuccess, (state, { messageId, emoji, oderId }) => { on(MessagesActions.removeReactionSuccess, (state, { messageId, emoji, oderId }) => {
const message = state.entities[messageId]; const message = state.entities[messageId];
if (!message) return state;
if (!message)
return state;
return messagesAdapter.updateOne( return messagesAdapter.updateOne(
{ {
@@ -140,8 +146,8 @@ export const messagesReducer = createReducer(
changes: { changes: {
reactions: message.reactions.filter( reactions: message.reactions.filter(
(existingReaction) => !(existingReaction.emoji === emoji && existingReaction.userId === oderId) (existingReaction) => !(existingReaction.emoji === emoji && existingReaction.userId === oderId)
), )
}, }
}, },
state state
); );
@@ -150,32 +156,43 @@ export const messagesReducer = createReducer(
// Sync lifecycle // Sync lifecycle
on(MessagesActions.startSync, (state) => ({ on(MessagesActions.startSync, (state) => ({
...state, ...state,
syncing: true, syncing: true
})), })),
on(MessagesActions.syncComplete, (state) => ({ on(MessagesActions.syncComplete, (state) => ({
...state, ...state,
syncing: false, syncing: false
})), })),
// Sync messages from peer (merge reactions to avoid losing local-only reactions) // Sync messages from peer (merge reactions to avoid losing local-only reactions)
on(MessagesActions.syncMessages, (state, { messages }) => { on(MessagesActions.syncMessages, (state, { messages }) => {
const merged = messages.map(message => { const merged = messages.map(message => {
const existing = state.entities[message.id]; const existing = state.entities[message.id];
if (existing?.reactions?.length) { if (existing?.reactions?.length) {
const combined = [...(message.reactions ?? [])]; const combined = [...(message.reactions ?? [])];
for (const existingReaction of existing.reactions) { for (const existingReaction of existing.reactions) {
if (!combined.some(combinedReaction => combinedReaction.userId === existingReaction.userId && combinedReaction.emoji === existingReaction.emoji && combinedReaction.messageId === existingReaction.messageId)) { const alreadyExists = combined.some((combinedReaction) =>
combinedReaction.userId === existingReaction.userId &&
combinedReaction.emoji === existingReaction.emoji &&
combinedReaction.messageId === existingReaction.messageId
);
if (!alreadyExists) {
combined.push(existingReaction); combined.push(existingReaction);
} }
} }
return { ...message, reactions: combined }; return { ...message, reactions: combined };
} }
return message; return message;
}); });
return messagesAdapter.upsertMany(merged, { return messagesAdapter.upsertMany(merged, {
...state, ...state,
syncing: false, syncing: false
}); });
}), }),
@@ -183,7 +200,7 @@ export const messagesReducer = createReducer(
on(MessagesActions.clearMessages, (state) => on(MessagesActions.clearMessages, (state) =>
messagesAdapter.removeAll({ messagesAdapter.removeAll({
...state, ...state,
currentRoomId: null, currentRoomId: null
}) })
) )
); );

View File

@@ -55,7 +55,9 @@ export const selectChannelMessages = (channelId: string) =>
selectAllMessages, selectAllMessages,
selectCurrentRoomId, selectCurrentRoomId,
(messages, roomId) => { (messages, roomId) => {
if (!roomId) return []; if (!roomId)
return [];
return messages.filter( return messages.filter(
(message) => message.roomId === roomId && (message.channelId || 'general') === channelId (message) => message.roomId === roomId && (message.channelId || 'general') === channelId
); );

View File

@@ -57,6 +57,6 @@ export const RoomsActions = createActionGroup({
'Rename Channel': props<{ channelId: string; name: string }>(), 'Rename Channel': props<{ channelId: string; name: string }>(),
'Clear Search Results': emptyProps(), 'Clear Search Results': emptyProps(),
'Set Connecting': props<{ isConnecting: boolean }>(), 'Set Connecting': props<{ isConnecting: boolean }>()
}, }
}); });

View File

@@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/member-ordering */
/* eslint-disable id-length */
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Actions, createEffect, ofType } from '@ngrx/effects';
@@ -11,7 +14,7 @@ import {
tap, tap,
debounceTime, debounceTime,
switchMap, switchMap,
filter, filter
} from 'rxjs/operators'; } from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { RoomsActions } from './rooms.actions'; import { RoomsActions } from './rooms.actions';
@@ -28,7 +31,7 @@ import { NotificationAudioService, AppSound } from '../../core/services/notifica
/** Build a minimal User object from signaling payload. */ /** Build a minimal User object from signaling payload. */
function buildSignalingUser( function buildSignalingUser(
data: { oderId: string; displayName: string }, data: { oderId: string; displayName: string },
extras: Record<string, unknown> = {}, extras: Record<string, unknown> = {}
) { ) {
return { return {
oderId: data.oderId, oderId: data.oderId,
@@ -39,14 +42,14 @@ function buildSignalingUser(
isOnline: true, isOnline: true,
role: 'member' as const, role: 'member' as const,
joinedAt: Date.now(), joinedAt: Date.now(),
...extras, ...extras
}; };
} }
/** Returns true when the message's server ID does not match the viewed server. */ /** Returns true when the message's server ID does not match the viewed server. */
function isWrongServer( function isWrongServer(
msgServerId: string | undefined, msgServerId: string | undefined,
viewedServerId: string | undefined, viewedServerId: string | undefined
): boolean { ): boolean {
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId); return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
} }
@@ -68,10 +71,10 @@ export class RoomsEffects {
switchMap(() => switchMap(() =>
from(this.db.getAllRooms()).pipe( from(this.db.getAllRooms()).pipe(
map((rooms) => RoomsActions.loadRoomsSuccess({ rooms })), map((rooms) => RoomsActions.loadRoomsSuccess({ rooms })),
catchError((error) => of(RoomsActions.loadRoomsFailure({ error: error.message }))), catchError((error) => of(RoomsActions.loadRoomsFailure({ error: error.message })))
), )
), )
), )
); );
/** Searches the server directory with debounced input. */ /** Searches the server directory with debounced input. */
@@ -82,10 +85,10 @@ export class RoomsEffects {
switchMap(({ query }) => switchMap(({ query }) =>
this.serverDirectory.searchServers(query).pipe( this.serverDirectory.searchServers(query).pipe(
map((servers) => RoomsActions.searchServersSuccess({ servers })), map((servers) => RoomsActions.searchServersSuccess({ servers })),
catchError((error) => of(RoomsActions.searchServersFailure({ error: error.message }))), catchError((error) => of(RoomsActions.searchServersFailure({ error: error.message })))
), )
), )
), )
); );
/** Creates a new room, saves it locally, and registers it with the server directory. */ /** Creates a new room, saves it locally, and registers it with the server directory. */
@@ -108,7 +111,7 @@ export class RoomsEffects {
password, password,
createdAt: Date.now(), createdAt: Date.now(),
userCount: 1, userCount: 1,
maxUsers: 50, maxUsers: 50
}; };
// Save to local DB // Save to local DB
@@ -126,14 +129,14 @@ export class RoomsEffects {
isPrivate: room.isPrivate, isPrivate: room.isPrivate,
userCount: 1, userCount: 1,
maxUsers: room.maxUsers || 50, maxUsers: room.maxUsers || 50,
tags: [], tags: []
}) })
.subscribe(); .subscribe();
return of(RoomsActions.createRoomSuccess({ room })); return of(RoomsActions.createRoomSuccess({ room }));
}), }),
catchError((error) => of(RoomsActions.createRoomFailure({ error: error.message }))), catchError((error) => of(RoomsActions.createRoomFailure({ error: error.message })))
), )
); );
/** Joins an existing room by ID, resolving room data from local DB or server directory. */ /** Joins an existing room by ID, resolving room data from local DB or server directory. */
@@ -164,8 +167,9 @@ export class RoomsEffects {
password, password,
createdAt: Date.now(), createdAt: Date.now(),
userCount: 1, userCount: 1,
maxUsers: 50, maxUsers: 50
}; };
// Save to local DB for future reference // Save to local DB for future reference
this.db.saveRoom(newRoom); this.db.saveRoom(newRoom);
return of(RoomsActions.joinRoomSuccess({ room: newRoom })); return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
@@ -184,20 +188,22 @@ export class RoomsEffects {
password, password,
createdAt: serverData.createdAt || Date.now(), createdAt: serverData.createdAt || Date.now(),
userCount: serverData.userCount, userCount: serverData.userCount,
maxUsers: serverData.maxUsers, maxUsers: serverData.maxUsers
}; };
this.db.saveRoom(newRoom); this.db.saveRoom(newRoom);
return of(RoomsActions.joinRoomSuccess({ room: newRoom })); return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
} }
return of(RoomsActions.joinRoomFailure({ error: 'Room not found' })); return of(RoomsActions.joinRoomFailure({ error: 'Room not found' }));
}), }),
catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' }))), catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' })))
); );
}), }),
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message }))), catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
); );
}), })
), )
); );
/** Navigates to the room view and establishes or reuses a signaling connection. */ /** Navigates to the room view and establishes or reuses a signaling connection. */
@@ -224,14 +230,14 @@ export class RoomsEffects {
this.webrtc.joinRoom(room.id, oderId); this.webrtc.joinRoom(room.id, oderId);
} }
}, },
error: () => {}, error: () => {}
}); });
} }
this.router.navigate(['/room', room.id]); this.router.navigate(['/room', room.id]);
}), })
), ),
{ dispatch: false }, { dispatch: false }
); );
/** Switches the UI view to an already-joined server without leaving others. */ /** Switches the UI view to an already-joined server without leaving others. */
@@ -249,8 +255,8 @@ export class RoomsEffects {
this.router.navigate(['/room', room.id]); this.router.navigate(['/room', room.id]);
return of(RoomsActions.viewServerSuccess({ room })); return of(RoomsActions.viewServerSuccess({ room }));
}), })
), )
); );
/** Reloads messages and users when the viewed server changes. */ /** Reloads messages and users when the viewed server changes. */
@@ -260,17 +266,17 @@ export class RoomsEffects {
mergeMap(({ room }) => [ mergeMap(({ room }) => [
UsersActions.clearUsers(), UsersActions.clearUsers(),
MessagesActions.loadMessages({ roomId: room.id }), MessagesActions.loadMessages({ roomId: room.id }),
UsersActions.loadBans(), UsersActions.loadBans()
]), ])
), )
); );
/** Handles leave-room dispatches (navigation only, peers stay connected). */ /** Handles leave-room dispatches (navigation only, peers stay connected). */
leaveRoom$ = createEffect(() => leaveRoom$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.leaveRoom), ofType(RoomsActions.leaveRoom),
map(() => RoomsActions.leaveRoomSuccess()), map(() => RoomsActions.leaveRoomSuccess())
), )
); );
/** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */ /** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */
@@ -279,15 +285,15 @@ export class RoomsEffects {
ofType(RoomsActions.deleteRoom), ofType(RoomsActions.deleteRoom),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
filter( filter(
([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id, ([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id
), ),
switchMap(([{ roomId }]) => { switchMap(([{ roomId }]) => {
this.db.deleteRoom(roomId); this.db.deleteRoom(roomId);
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId }); this.webrtc.broadcastMessage({ type: 'room-deleted', roomId });
this.webrtc.disconnectAll(); this.webrtc.disconnectAll();
return of(RoomsActions.deleteRoomSuccess({ roomId })); return of(RoomsActions.deleteRoomSuccess({ roomId }));
}), })
), )
); );
/** Forgets a room locally: removes from DB and leaves the signaling server for that room. */ /** Forgets a room locally: removes from DB and leaves the signaling server for that room. */
@@ -303,8 +309,8 @@ export class RoomsEffects {
this.webrtc.leaveRoom(roomId); this.webrtc.leaveRoom(roomId);
return of(RoomsActions.forgetRoomSuccess({ roomId })); return of(RoomsActions.forgetRoomSuccess({ roomId }));
}), })
), )
); );
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */ /** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
@@ -321,8 +327,8 @@ export class RoomsEffects {
if (currentRoom.hostId !== currentUser.id && currentUser.role !== 'admin') { if (currentRoom.hostId !== currentUser.id && currentUser.role !== 'admin') {
return of( return of(
RoomsActions.updateRoomSettingsFailure({ RoomsActions.updateRoomSettingsFailure({
error: 'Permission denied', error: 'Permission denied'
}), })
); );
} }
@@ -332,7 +338,7 @@ export class RoomsEffects {
topic: settings.topic ?? currentRoom.topic, topic: settings.topic ?? currentRoom.topic,
isPrivate: settings.isPrivate ?? currentRoom.isPrivate, isPrivate: settings.isPrivate ?? currentRoom.isPrivate,
password: settings.password ?? currentRoom.password, password: settings.password ?? currentRoom.password,
maxUsers: settings.maxUsers ?? currentRoom.maxUsers, maxUsers: settings.maxUsers ?? currentRoom.maxUsers
}; };
// Update local DB // Update local DB
@@ -341,13 +347,13 @@ export class RoomsEffects {
// Broadcast to all peers // Broadcast to all peers
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'room-settings-update', type: 'room-settings-update',
settings: updatedSettings, settings: updatedSettings
}); });
return of(RoomsActions.updateRoomSettingsSuccess({ settings: updatedSettings })); return of(RoomsActions.updateRoomSettingsSuccess({ settings: updatedSettings }));
}), }),
catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message }))), catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message })))
), )
); );
/** Persists room field changes to the local database. */ /** Persists room field changes to the local database. */
@@ -360,9 +366,9 @@ export class RoomsEffects {
if (currentRoom && currentRoom.id === roomId) { if (currentRoom && currentRoom.id === roomId) {
this.db.updateRoom(roomId, changes); this.db.updateRoom(roomId, changes);
} }
}), })
), ),
{ dispatch: false }, { dispatch: false }
); );
/** Updates room permission grants (host-only) and broadcasts to peers. */ /** Updates room permission grants (host-only) and broadcasts to peers. */
@@ -375,21 +381,22 @@ export class RoomsEffects {
!!currentUser && !!currentUser &&
!!currentRoom && !!currentRoom &&
currentRoom.id === roomId && currentRoom.id === roomId &&
currentRoom.hostId === currentUser.id, currentRoom.hostId === currentUser.id
), ),
mergeMap(([{ roomId, permissions }, , currentRoom]) => { mergeMap(([{ roomId, permissions }, , currentRoom]) => {
const updated: Partial<Room> = { const updated: Partial<Room> = {
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions, permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions
}; };
this.db.updateRoom(roomId, updated); this.db.updateRoom(roomId, updated);
// Broadcast to peers // Broadcast to peers
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'room-permissions-update', type: 'room-permissions-update',
permissions: updated.permissions, permissions: updated.permissions
} as any); } as any);
return of(RoomsActions.updateRoom({ roomId, changes: updated })); return of(RoomsActions.updateRoom({ roomId, changes: updated }));
}), })
), )
); );
/** Updates the server icon (permission-enforced) and broadcasts to peers. */ /** Updates the server icon (permission-enforced) and broadcasts to peers. */
@@ -408,23 +415,25 @@ export class RoomsEffects {
const canByRole = const canByRole =
(role === 'admin' && perms.adminsManageIcon) || (role === 'admin' && perms.adminsManageIcon) ||
(role === 'moderator' && perms.moderatorsManageIcon); (role === 'moderator' && perms.moderatorsManageIcon);
if (!isOwner && !canByRole) { if (!isOwner && !canByRole) {
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' })); return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
} }
const iconUpdatedAt = Date.now(); const iconUpdatedAt = Date.now();
const changes: Partial<Room> = { icon, iconUpdatedAt }; const changes: Partial<Room> = { icon, iconUpdatedAt };
this.db.updateRoom(roomId, changes); this.db.updateRoom(roomId, changes);
// Broadcast to peers // Broadcast to peers
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'server-icon-update', type: 'server-icon-update',
roomId, roomId,
icon, icon,
iconUpdatedAt, iconUpdatedAt
} as any); } as any);
return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt })); return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt }));
}), })
), )
); );
/** Persists newly created room to the local database. */ /** Persists newly created room to the local database. */
@@ -434,17 +443,17 @@ export class RoomsEffects {
ofType(RoomsActions.createRoomSuccess), ofType(RoomsActions.createRoomSuccess),
tap(({ room }) => { tap(({ room }) => {
this.db.saveRoom(room); this.db.saveRoom(room);
}), })
), ),
{ dispatch: false }, { dispatch: false }
); );
/** Set the creator's role to 'host' after creating a room. */ /** Set the creator's role to 'host' after creating a room. */
setHostRoleOnCreate$ = createEffect(() => setHostRoleOnCreate$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.createRoomSuccess), ofType(RoomsActions.createRoomSuccess),
map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } })), map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } }))
), )
); );
/** Set the user's role to 'host' when rejoining a room they own. */ /** Set the user's role to 'host' when rejoining a room they own. */
@@ -453,8 +462,8 @@ export class RoomsEffects {
ofType(RoomsActions.joinRoomSuccess), ofType(RoomsActions.joinRoomSuccess),
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(this.store.select(selectCurrentUser)),
filter(([{ room }, user]) => !!user && !!room.hostId && room.hostId === user.id), filter(([{ room }, user]) => !!user && !!room.hostId && room.hostId === user.id),
map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } })), map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } }))
), )
); );
/** Loads messages and bans when joining a room. */ /** Loads messages and bans when joining a room. */
@@ -465,17 +474,17 @@ export class RoomsEffects {
MessagesActions.loadMessages({ roomId: room.id }), MessagesActions.loadMessages({ roomId: room.id }),
// Don't load users from database - they come from signaling server // Don't load users from database - they come from signaling server
// UsersActions.loadRoomUsers({ roomId: room.id }), // UsersActions.loadRoomUsers({ roomId: room.id }),
UsersActions.loadBans(), UsersActions.loadBans()
]), ])
), )
); );
/** Clears messages and users from the store when leaving a room. */ /** Clears messages and users from the store when leaving a room. */
onLeaveRoom$ = createEffect(() => onLeaveRoom$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.leaveRoomSuccess), ofType(RoomsActions.leaveRoomSuccess),
mergeMap(() => [MessagesActions.clearMessages(), UsersActions.clearUsers()]), mergeMap(() => [MessagesActions.clearMessages(), UsersActions.clearUsers()])
), )
); );
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */ /** Handles WebRTC signaling events for user presence (join, leave, server_users). */
@@ -488,26 +497,35 @@ export class RoomsEffects {
switch (message.type) { switch (message.type) {
case 'server_users': { case 'server_users': {
if (!message.users || isWrongServer(message.serverId, viewedServerId)) return EMPTY; if (!message.users || isWrongServer(message.serverId, viewedServerId))
return EMPTY;
const joinActions = (message.users as { oderId: string; displayName: string }[]) const joinActions = (message.users as { oderId: string; displayName: string }[])
.filter((u) => u.oderId !== myId) .filter((u) => u.oderId !== myId)
.map((u) => UsersActions.userJoined({ user: buildSignalingUser(u) })); .map((u) => UsersActions.userJoined({ user: buildSignalingUser(u) }));
return [UsersActions.clearUsers(), ...joinActions]; return [UsersActions.clearUsers(), ...joinActions];
} }
case 'user_joined': { case 'user_joined': {
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId)
return EMPTY; return EMPTY;
return [UsersActions.userJoined({ user: buildSignalingUser(message) })]; return [UsersActions.userJoined({ user: buildSignalingUser(message) })];
} }
case 'user_left': { case 'user_left': {
if (isWrongServer(message.serverId, viewedServerId)) return EMPTY; if (isWrongServer(message.serverId, viewedServerId))
return EMPTY;
return [UsersActions.userLeft({ userId: message.oderId })]; return [UsersActions.userLeft({ userId: message.oderId })];
} }
default: default:
return EMPTY; return EMPTY;
} }
}), })
), )
); );
/** Processes incoming P2P room and icon-sync events. */ /** Processes incoming P2P room and icon-sync events. */
@@ -517,6 +535,7 @@ export class RoomsEffects {
filter(([, room]) => !!room), filter(([, room]) => !!room),
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => { mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
const room = currentRoom as Room; const room = currentRoom as Room;
switch (event.type) { switch (event.type) {
case 'voice-state': case 'voice-state':
return this.handleVoiceOrScreenState(event, allUsers, 'voice'); return this.handleVoiceOrScreenState(event, allUsers, 'voice');
@@ -534,22 +553,27 @@ export class RoomsEffects {
default: default:
return EMPTY; return EMPTY;
} }
}), })
), )
); );
private handleVoiceOrScreenState(event: any, allUsers: any[], kind: 'voice' | 'screen') { private handleVoiceOrScreenState(event: any, allUsers: any[], kind: 'voice' | 'screen') {
const userId: string | undefined = event.fromPeerId ?? event.oderId; const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId) return EMPTY;
if (!userId)
return EMPTY;
const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId); const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId);
if (kind === 'voice') { if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined; const vs = event.voiceState as Partial<VoiceState> | undefined;
if (!vs) return EMPTY;
if (!vs)
return EMPTY;
// Detect voice-connection transitions to play join/leave sounds. // Detect voice-connection transitions to play join/leave sounds.
const weAreInVoice = this.webrtc.isVoiceConnected(); const weAreInVoice = this.webrtc.isVoiceConnected();
if (weAreInVoice) { if (weAreInVoice) {
const existingUser = allUsers.find((u) => u.id === userId || u.oderId === userId) as any; const existingUser = allUsers.find((u) => u.id === userId || u.oderId === userId) as any;
const wasConnected = existingUser?.voiceState?.isConnected ?? false; const wasConnected = existingUser?.voiceState?.isConnected ?? false;
@@ -576,41 +600,48 @@ export class RoomsEffects {
isMutedByAdmin: vs.isMutedByAdmin, isMutedByAdmin: vs.isMutedByAdmin,
volume: vs.volume, volume: vs.volume,
roomId: vs.roomId, roomId: vs.roomId,
serverId: vs.serverId, serverId: vs.serverId
}, }
}, }
), )
}), })
); );
} }
return of(UsersActions.updateVoiceState({ userId, voiceState: vs })); return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
} }
// screen-state // screen-state
const isSharing = event.isScreenSharing as boolean | undefined; const isSharing = event.isScreenSharing as boolean | undefined;
if (isSharing === undefined) return EMPTY;
if (isSharing === undefined)
return EMPTY;
if (!userExists) { if (!userExists) {
return of( return of(
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser( user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || 'User' }, { oderId: userId, displayName: event.displayName || 'User' },
{ screenShareState: { isSharing } }, { screenShareState: { isSharing } }
), )
}), })
); );
} }
return of( return of(
UsersActions.updateScreenShareState({ UsersActions.updateScreenShareState({
userId, userId,
screenShareState: { isSharing }, screenShareState: { isSharing }
}), })
); );
} }
private handleRoomSettingsUpdate(event: any, room: Room) { private handleRoomSettingsUpdate(event: any, room: Room) {
const settings: RoomSettings | undefined = event.settings; const settings: RoomSettings | undefined = event.settings;
if (!settings) return EMPTY;
if (!settings)
return EMPTY;
this.db.updateRoom(room.id, settings); this.db.updateRoom(room.id, settings);
return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> })); return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> }));
} }
@@ -618,12 +649,14 @@ export class RoomsEffects {
private handleIconSummary(event: any, room: Room) { private handleIconSummary(event: any, room: Room) {
const remoteUpdated = event.iconUpdatedAt || 0; const remoteUpdated = event.iconUpdatedAt || 0;
const localUpdated = room.iconUpdatedAt || 0; const localUpdated = room.iconUpdatedAt || 0;
if (remoteUpdated > localUpdated && event.fromPeerId) { if (remoteUpdated > localUpdated && event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, { this.webrtc.sendToPeer(event.fromPeerId, {
type: 'server-icon-request', type: 'server-icon-request',
roomId: room.id, roomId: room.id
} as any); } as any);
} }
return EMPTY; return EMPTY;
} }
@@ -633,34 +666,42 @@ export class RoomsEffects {
type: 'server-icon-full', type: 'server-icon-full',
roomId: room.id, roomId: room.id,
icon: room.icon, icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt || 0, iconUpdatedAt: room.iconUpdatedAt || 0
} as any); } as any);
} }
return EMPTY; return EMPTY;
} }
private handleIconData(event: any, room: Room) { private handleIconData(event: any, room: Room) {
const senderId = event.fromPeerId as string | undefined; const senderId = event.fromPeerId as string | undefined;
if (typeof event.icon !== 'string' || !senderId) return EMPTY;
if (typeof event.icon !== 'string' || !senderId)
return EMPTY;
return this.store.select(selectAllUsers).pipe( return this.store.select(selectAllUsers).pipe(
map((users) => users.find((u) => u.id === senderId)), map((users) => users.find((u) => u.id === senderId)),
mergeMap((sender) => { mergeMap((sender) => {
if (!sender) return EMPTY; if (!sender)
return EMPTY;
const perms = room.permissions || {}; const perms = room.permissions || {};
const isOwner = room.hostId === sender.id; const isOwner = room.hostId === sender.id;
const canByRole = const canByRole =
(sender.role === 'admin' && perms.adminsManageIcon) || (sender.role === 'admin' && perms.adminsManageIcon) ||
(sender.role === 'moderator' && perms.moderatorsManageIcon); (sender.role === 'moderator' && perms.moderatorsManageIcon);
if (!isOwner && !canByRole) return EMPTY;
if (!isOwner && !canByRole)
return EMPTY;
const updates: Partial<Room> = { const updates: Partial<Room> = {
icon: event.icon, icon: event.icon,
iconUpdatedAt: event.iconUpdatedAt || Date.now(), iconUpdatedAt: event.iconUpdatedAt || Date.now()
}; };
this.db.updateRoom(room.id, updates); this.db.updateRoom(room.id, updates);
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates })); return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
}), })
); );
} }
@@ -670,15 +711,18 @@ export class RoomsEffects {
this.webrtc.onPeerConnected.pipe( this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([peerId, room]) => { tap(([peerId, room]) => {
if (!room) return; if (!room)
return;
const iconUpdatedAt = room.iconUpdatedAt || 0; const iconUpdatedAt = room.iconUpdatedAt || 0;
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'server-icon-summary', type: 'server-icon-summary',
roomId: room.id, roomId: room.id,
iconUpdatedAt, iconUpdatedAt
} as any); } as any);
}), })
), ),
{ dispatch: false }, { dispatch: false }
); );
} }

View File

@@ -8,27 +8,32 @@ export function defaultChannels(): Channel[] {
{ id: 'general', name: 'general', type: 'text', position: 0 }, { id: 'general', name: 'general', type: 'text', position: 0 },
{ id: 'random', name: 'random', type: 'text', position: 1 }, { id: 'random', name: 'random', type: 'text', position: 1 },
{ id: 'vc-general', name: 'General', type: 'voice', position: 0 }, { id: 'vc-general', name: 'General', type: 'voice', position: 0 },
{ id: 'vc-afk', name: 'AFK', type: 'voice', position: 1 }, { id: 'vc-afk', name: 'AFK', type: 'voice', position: 1 }
]; ];
} }
/** Deduplicate rooms by id, keeping the last occurrence */ /** Deduplicate rooms by id, keeping the last occurrence */
function deduplicateRooms(rooms: Room[]): Room[] { function deduplicateRooms(rooms: Room[]): Room[] {
const seen = new Map<string, Room>(); const seen = new Map<string, Room>();
for (const room of rooms) { for (const room of rooms) {
seen.set(room.id, room); seen.set(room.id, room);
} }
return Array.from(seen.values()); return Array.from(seen.values());
} }
/** Upsert a room into a saved-rooms list (add or replace by id) */ /** Upsert a room into a saved-rooms list (add or replace by id) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] { function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id); const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
if (idx >= 0) { if (idx >= 0) {
const updated = [...savedRooms]; const updated = [...savedRooms];
updated[idx] = room; updated[idx] = room;
return updated; return updated;
} }
return [...savedRooms, room]; return [...savedRooms, room];
} }
@@ -66,7 +71,7 @@ export const initialState: RoomsState = {
isConnected: false, isConnected: false,
loading: false, loading: false,
error: null, error: null,
activeChannelId: 'general', activeChannelId: 'general'
}; };
export const roomsReducer = createReducer( export const roomsReducer = createReducer(
@@ -76,94 +81,96 @@ export const roomsReducer = createReducer(
on(RoomsActions.loadRooms, (state) => ({ on(RoomsActions.loadRooms, (state) => ({
...state, ...state,
loading: true, loading: true,
error: null, error: null
})), })),
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({ on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
...state, ...state,
savedRooms: deduplicateRooms(rooms), savedRooms: deduplicateRooms(rooms),
loading: false, loading: false
})), })),
on(RoomsActions.loadRoomsFailure, (state, { error }) => ({ on(RoomsActions.loadRoomsFailure, (state, { error }) => ({
...state, ...state,
loading: false, loading: false,
error, error
})), })),
// Search servers // Search servers
on(RoomsActions.searchServers, (state) => ({ on(RoomsActions.searchServers, (state) => ({
...state, ...state,
isSearching: true, isSearching: true,
error: null, error: null
})), })),
on(RoomsActions.searchServersSuccess, (state, { servers }) => ({ on(RoomsActions.searchServersSuccess, (state, { servers }) => ({
...state, ...state,
searchResults: servers, searchResults: servers,
isSearching: false, isSearching: false
})), })),
on(RoomsActions.searchServersFailure, (state, { error }) => ({ on(RoomsActions.searchServersFailure, (state, { error }) => ({
...state, ...state,
isSearching: false, isSearching: false,
error, error
})), })),
// Create room // Create room
on(RoomsActions.createRoom, (state) => ({ on(RoomsActions.createRoom, (state) => ({
...state, ...state,
isConnecting: true, isConnecting: true,
error: null, error: null
})), })),
on(RoomsActions.createRoomSuccess, (state, { room }) => { on(RoomsActions.createRoomSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() }; const enriched = { ...room, channels: room.channels || defaultChannels() };
return { return {
...state, ...state,
currentRoom: enriched, currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched), savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false, isConnecting: false,
isConnected: true, isConnected: true,
activeChannelId: 'general', activeChannelId: 'general'
}; };
}), }),
on(RoomsActions.createRoomFailure, (state, { error }) => ({ on(RoomsActions.createRoomFailure, (state, { error }) => ({
...state, ...state,
isConnecting: false, isConnecting: false,
error, error
})), })),
// Join room // Join room
on(RoomsActions.joinRoom, (state) => ({ on(RoomsActions.joinRoom, (state) => ({
...state, ...state,
isConnecting: true, isConnecting: true,
error: null, error: null
})), })),
on(RoomsActions.joinRoomSuccess, (state, { room }) => { on(RoomsActions.joinRoomSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() }; const enriched = { ...room, channels: room.channels || defaultChannels() };
return { return {
...state, ...state,
currentRoom: enriched, currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched), savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false, isConnecting: false,
isConnected: true, isConnected: true,
activeChannelId: 'general', activeChannelId: 'general'
}; };
}), }),
on(RoomsActions.joinRoomFailure, (state, { error }) => ({ on(RoomsActions.joinRoomFailure, (state, { error }) => ({
...state, ...state,
isConnecting: false, isConnecting: false,
error, error
})), })),
// Leave room // Leave room
on(RoomsActions.leaveRoom, (state) => ({ on(RoomsActions.leaveRoom, (state) => ({
...state, ...state,
isConnecting: true, isConnecting: true
})), })),
on(RoomsActions.leaveRoomSuccess, (state) => ({ on(RoomsActions.leaveRoomSuccess, (state) => ({
@@ -171,32 +178,33 @@ export const roomsReducer = createReducer(
currentRoom: null, currentRoom: null,
roomSettings: null, roomSettings: null,
isConnecting: false, isConnecting: false,
isConnected: false, isConnected: false
})), })),
// View server just switch the viewed room, stay connected // View server just switch the viewed room, stay connected
on(RoomsActions.viewServer, (state) => ({ on(RoomsActions.viewServer, (state) => ({
...state, ...state,
isConnecting: true, isConnecting: true,
error: null, error: null
})), })),
on(RoomsActions.viewServerSuccess, (state, { room }) => { on(RoomsActions.viewServerSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() }; const enriched = { ...room, channels: room.channels || defaultChannels() };
return { return {
...state, ...state,
currentRoom: enriched, currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched), savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false, isConnecting: false,
isConnected: true, isConnected: true,
activeChannelId: 'general', activeChannelId: 'general'
}; };
}), }),
// Update room settings // Update room settings
on(RoomsActions.updateRoomSettings, (state) => ({ on(RoomsActions.updateRoomSettings, (state) => ({
...state, ...state,
error: null, error: null
})), })),
on(RoomsActions.updateRoomSettingsSuccess, (state, { settings }) => ({ on(RoomsActions.updateRoomSettingsSuccess, (state, { settings }) => ({
@@ -204,41 +212,41 @@ export const roomsReducer = createReducer(
roomSettings: settings, roomSettings: settings,
currentRoom: state.currentRoom currentRoom: state.currentRoom
? { ? {
...state.currentRoom, ...state.currentRoom,
name: settings.name, name: settings.name,
description: settings.description, description: settings.description,
topic: settings.topic, topic: settings.topic,
isPrivate: settings.isPrivate, isPrivate: settings.isPrivate,
password: settings.password, password: settings.password,
maxUsers: settings.maxUsers, maxUsers: settings.maxUsers
} }
: null, : null
})), })),
on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({ on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({
...state, ...state,
error, error
})), })),
// Delete room // Delete room
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({ on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
...state, ...state,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId), savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom, currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})), })),
// Forget room (local only) // Forget room (local only)
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({ on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
...state, ...state,
savedRooms: state.savedRooms.filter((room) => room.id !== roomId), savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom, currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
})), })),
// Set current room // Set current room
on(RoomsActions.setCurrentRoom, (state, { room }) => ({ on(RoomsActions.setCurrentRoom, (state, { room }) => ({
...state, ...state,
currentRoom: room, currentRoom: room,
isConnected: true, isConnected: true
})), })),
// Clear current room // Clear current room
@@ -246,85 +254,98 @@ export const roomsReducer = createReducer(
...state, ...state,
currentRoom: null, currentRoom: null,
roomSettings: null, roomSettings: null,
isConnected: false, isConnected: false
})), })),
// Update room // Update room
on(RoomsActions.updateRoom, (state, { roomId, changes }) => { on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
if (state.currentRoom?.id !== roomId) return state; if (state.currentRoom?.id !== roomId)
return state;
return { return {
...state, ...state,
currentRoom: { ...state.currentRoom, ...changes }, currentRoom: { ...state.currentRoom, ...changes }
}; };
}), }),
// Update server icon success // Update server icon success
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => { on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
if (state.currentRoom?.id !== roomId) return state; if (state.currentRoom?.id !== roomId)
return state;
return { return {
...state, ...state,
currentRoom: { ...state.currentRoom, icon, iconUpdatedAt }, currentRoom: { ...state.currentRoom, icon, iconUpdatedAt }
}; };
}), }),
// Receive room update // Receive room update
on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({ on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({
...state, ...state,
currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null, currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null
})), })),
// Clear search results // Clear search results
on(RoomsActions.clearSearchResults, (state) => ({ on(RoomsActions.clearSearchResults, (state) => ({
...state, ...state,
searchResults: [], searchResults: []
})), })),
// Set connecting // Set connecting
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({ on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
...state, ...state,
isConnecting, isConnecting
})), })),
// Channel management // Channel management
on(RoomsActions.selectChannel, (state, { channelId }) => ({ on(RoomsActions.selectChannel, (state, { channelId }) => ({
...state, ...state,
activeChannelId: channelId, activeChannelId: channelId
})), })),
on(RoomsActions.addChannel, (state, { channel }) => { on(RoomsActions.addChannel, (state, { channel }) => {
if (!state.currentRoom) return state; if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = [...existing, channel]; const updatedChannels = [...existing, channel];
const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return { return {
...state, ...state,
currentRoom: updatedRoom, currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom), savedRooms: upsertRoom(state.savedRooms, updatedRoom)
}; };
}), }),
on(RoomsActions.removeChannel, (state, { channelId }) => { on(RoomsActions.removeChannel, (state, { channelId }) => {
if (!state.currentRoom) return state; if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.filter(channel => channel.id !== channelId); const updatedChannels = existing.filter(channel => channel.id !== channelId);
const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return { return {
...state, ...state,
currentRoom: updatedRoom, currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom), savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.activeChannelId === channelId ? 'general' : state.activeChannelId, activeChannelId: state.activeChannelId === channelId ? 'general' : state.activeChannelId
}; };
}), }),
on(RoomsActions.renameChannel, (state, { channelId, name }) => { on(RoomsActions.renameChannel, (state, { channelId, name }) => {
if (!state.currentRoom) return state; if (!state.currentRoom)
return state;
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel); const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel);
const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return { return {
...state, ...state,
currentRoom: updatedRoom, currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom), savedRooms: upsertRoom(state.savedRooms, updatedRoom)
}; };
}) })
); );

View File

@@ -91,11 +91,15 @@ export const selectCurrentRoomChannels = createSelector(
/** Selects only text channels, sorted by position. */ /** Selects only text channels, sorted by position. */
export const selectTextChannels = createSelector( export const selectTextChannels = createSelector(
selectCurrentRoomChannels, selectCurrentRoomChannels,
(channels) => channels.filter(channel => channel.type === 'text').sort((a, b) => a.position - b.position) (channels) => channels
.filter((channel) => channel.type === 'text')
.sort((channelA, channelB) => channelA.position - channelB.position)
); );
/** Selects only voice channels, sorted by position. */ /** Selects only voice channels, sorted by position. */
export const selectVoiceChannels = createSelector( export const selectVoiceChannels = createSelector(
selectCurrentRoomChannels, selectCurrentRoomChannels,
(channels) => channels.filter(channel => channel.type === 'voice').sort((a, b) => a.position - b.position) (channels) => channels
.filter((channel) => channel.type === 'voice')
.sort((channelA, channelB) => channelA.position - channelB.position)
); );

View File

@@ -43,6 +43,6 @@ export const UsersActions = createActionGroup({
'Update Host': props<{ userId: string }>(), 'Update Host': props<{ userId: string }>(),
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(), 'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(), 'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>()
}, }
}); });

View File

@@ -1,6 +1,7 @@
/** /**
* Users store effects (load, kick, ban, host election, profile persistence). * Users store effects (load, kick, ban, host election, profile persistence).
*/ */
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -32,6 +33,7 @@ export class UsersEffects {
if (user) { if (user) {
return UsersActions.loadCurrentUserSuccess({ user }); return UsersActions.loadCurrentUserSuccess({ user });
} }
return UsersActions.loadCurrentUserFailure({ error: 'No current user' }); return UsersActions.loadCurrentUserFailure({ error: 'No current user' });
}), }),
catchError((error) => catchError((error) =>
@@ -63,27 +65,30 @@ export class UsersEffects {
ofType(UsersActions.kickUser), ofType(UsersActions.kickUser),
withLatestFrom( withLatestFrom(
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom), this.store.select(selectCurrentRoom)
), ),
mergeMap(([{ userId }, currentUser, currentRoom]) => { mergeMap(([{ userId }, currentUser, currentRoom]) => {
if (!currentUser || !currentRoom) return EMPTY; if (!currentUser || !currentRoom)
return EMPTY;
const canKick = const canKick =
currentUser.role === 'host' || currentUser.role === 'host' ||
currentUser.role === 'admin' || currentUser.role === 'admin' ||
currentUser.role === 'moderator'; currentUser.role === 'moderator';
if (!canKick) return EMPTY;
if (!canKick)
return EMPTY;
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'kick', type: 'kick',
targetUserId: userId, targetUserId: userId,
roomId: currentRoom.id, roomId: currentRoom.id,
kickedBy: currentUser.id, kickedBy: currentUser.id
}); });
return of(UsersActions.kickUserSuccess({ userId })); return of(UsersActions.kickUserSuccess({ userId }));
}), })
), )
); );
/** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */ /** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */
@@ -92,13 +97,16 @@ export class UsersEffects {
ofType(UsersActions.banUser), ofType(UsersActions.banUser),
withLatestFrom( withLatestFrom(
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom), this.store.select(selectCurrentRoom)
), ),
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => { mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
if (!currentUser || !currentRoom) return EMPTY; if (!currentUser || !currentRoom)
return EMPTY;
const canBan = currentUser.role === 'host' || currentUser.role === 'admin'; const canBan = currentUser.role === 'host' || currentUser.role === 'admin';
if (!canBan) return EMPTY;
if (!canBan)
return EMPTY;
const ban: BanEntry = { const ban: BanEntry = {
oderId: uuidv4(), oderId: uuidv4(),
@@ -107,7 +115,7 @@ export class UsersEffects {
bannedBy: currentUser.id, bannedBy: currentUser.id,
reason, reason,
expiresAt, expiresAt,
timestamp: Date.now(), timestamp: Date.now()
}; };
this.db.saveBan(ban); this.db.saveBan(ban);
@@ -116,12 +124,12 @@ export class UsersEffects {
targetUserId: userId, targetUserId: userId,
roomId: currentRoom.id, roomId: currentRoom.id,
bannedBy: currentUser.id, bannedBy: currentUser.id,
reason, reason
}); });
return of(UsersActions.banUserSuccess({ userId, ban })); return of(UsersActions.banUserSuccess({ userId, ban }));
}), })
), )
); );
/** Removes a ban entry from the local database. */ /** Removes a ban entry from the local database. */
@@ -146,6 +154,7 @@ export class UsersEffects {
if (!currentRoom) { if (!currentRoom) {
return of(UsersActions.loadBansSuccess({ bans: [] })); return of(UsersActions.loadBansSuccess({ bans: [] }));
} }
return from(this.db.getBansForRoom(currentRoom.id)).pipe( return from(this.db.getBansForRoom(currentRoom.id)).pipe(
map((bans) => UsersActions.loadBansSuccess({ bans })), map((bans) => UsersActions.loadBansSuccess({ bans })),
catchError(() => of(UsersActions.loadBansSuccess({ bans: [] }))) catchError(() => of(UsersActions.loadBansSuccess({ bans: [] })))
@@ -165,8 +174,8 @@ export class UsersEffects {
mergeMap(([{ userId }, hostId, currentUserId]) => mergeMap(([{ userId }, hostId, currentUserId]) =>
userId === hostId && currentUserId userId === hostId && currentUserId
? of(UsersActions.updateHost({ userId: currentUserId })) ? of(UsersActions.updateHost({ userId: currentUserId }))
: EMPTY, : EMPTY
), )
) )
); );

View File

@@ -19,7 +19,7 @@ export interface UsersState extends EntityState<User> {
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({ export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
selectId: (user) => user.id, selectId: (user) => user.id,
sortComparer: (a, b) => a.username.localeCompare(b.username), sortComparer: (userA, userB) => userA.username.localeCompare(userB.username)
}); });
export const initialState: UsersState = usersAdapter.getInitialState({ export const initialState: UsersState = usersAdapter.getInitialState({
@@ -27,7 +27,7 @@ export const initialState: UsersState = usersAdapter.getInitialState({
hostId: null, hostId: null,
loading: false, loading: false,
error: null, error: null,
bans: [], bans: []
}); });
export const usersReducer = createReducer( export const usersReducer = createReducer(
@@ -37,38 +37,40 @@ export const usersReducer = createReducer(
on(UsersActions.loadCurrentUser, (state) => ({ on(UsersActions.loadCurrentUser, (state) => ({
...state, ...state,
loading: true, loading: true,
error: null, error: null
})), })),
on(UsersActions.loadCurrentUserSuccess, (state, { user }) => on(UsersActions.loadCurrentUserSuccess, (state, { user }) =>
usersAdapter.upsertOne(user, { usersAdapter.upsertOne(user, {
...state, ...state,
currentUserId: user.id, currentUserId: user.id,
loading: false, loading: false
}) })
), ),
on(UsersActions.loadCurrentUserFailure, (state, { error }) => ({ on(UsersActions.loadCurrentUserFailure, (state, { error }) => ({
...state, ...state,
loading: false, loading: false,
error, error
})), })),
// Set current user // Set current user
on(UsersActions.setCurrentUser, (state, { user }) => on(UsersActions.setCurrentUser, (state, { user }) =>
usersAdapter.upsertOne(user, { usersAdapter.upsertOne(user, {
...state, ...state,
currentUserId: user.id, currentUserId: user.id
}) })
), ),
// Update current user // Update current user
on(UsersActions.updateCurrentUser, (state, { updates }) => { on(UsersActions.updateCurrentUser, (state, { updates }) => {
if (!state.currentUserId) return state; if (!state.currentUserId)
return state;
return usersAdapter.updateOne( return usersAdapter.updateOne(
{ {
id: state.currentUserId, id: state.currentUserId,
changes: updates, changes: updates
}, },
state state
); );
@@ -78,20 +80,20 @@ export const usersReducer = createReducer(
on(UsersActions.loadRoomUsers, (state) => ({ on(UsersActions.loadRoomUsers, (state) => ({
...state, ...state,
loading: true, loading: true,
error: null, error: null
})), })),
on(UsersActions.loadRoomUsersSuccess, (state, { users }) => on(UsersActions.loadRoomUsersSuccess, (state, { users }) =>
usersAdapter.upsertMany(users, { usersAdapter.upsertMany(users, {
...state, ...state,
loading: false, loading: false
}) })
), ),
on(UsersActions.loadRoomUsersFailure, (state, { error }) => ({ on(UsersActions.loadRoomUsersFailure, (state, { error }) => ({
...state, ...state,
loading: false, loading: false,
error, error
})), })),
// User joined // User joined
@@ -109,7 +111,7 @@ export const usersReducer = createReducer(
usersAdapter.updateOne( usersAdapter.updateOne(
{ {
id: userId, id: userId,
changes: updates, changes: updates
}, },
state state
) )
@@ -120,7 +122,7 @@ export const usersReducer = createReducer(
usersAdapter.updateOne( usersAdapter.updateOne(
{ {
id: userId, id: userId,
changes: { role }, changes: { role }
}, },
state state
) )
@@ -134,22 +136,23 @@ export const usersReducer = createReducer(
// Ban user // Ban user
on(UsersActions.banUserSuccess, (state, { userId, ban }) => { on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
const newState = usersAdapter.removeOne(userId, state); const newState = usersAdapter.removeOne(userId, state);
return { return {
...newState, ...newState,
bans: [...state.bans, ban], bans: [...state.bans, ban]
}; };
}), }),
// Unban user // Unban user
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({ on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
...state, ...state,
bans: state.bans.filter((ban) => ban.oderId !== oderId), bans: state.bans.filter((ban) => ban.oderId !== oderId)
})), })),
// Load bans // Load bans
on(UsersActions.loadBansSuccess, (state, { bans }) => ({ on(UsersActions.loadBansSuccess, (state, { bans }) => ({
...state, ...state,
bans, bans
})), })),
// Admin mute // Admin mute
@@ -164,9 +167,9 @@ export const usersReducer = createReducer(
isMuted: true, isMuted: true,
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false, isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
isSpeaking: false, isSpeaking: false,
isMutedByAdmin: true, isMutedByAdmin: true
}, }
}, }
}, },
state state
) )
@@ -184,9 +187,9 @@ export const usersReducer = createReducer(
isMuted: state.entities[userId]?.voiceState?.isMuted ?? false, isMuted: state.entities[userId]?.voiceState?.isMuted ?? false,
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false, isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
isSpeaking: state.entities[userId]?.voiceState?.isSpeaking ?? false, isSpeaking: state.entities[userId]?.voiceState?.isSpeaking ?? false,
isMutedByAdmin: false, isMutedByAdmin: false
}, }
}, }
}, },
state state
) )
@@ -198,8 +201,9 @@ export const usersReducer = createReducer(
isConnected: false, isConnected: false,
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
isSpeaking: false, isSpeaking: false
}; };
return usersAdapter.updateOne( return usersAdapter.updateOne(
{ {
id: userId, id: userId,
@@ -213,9 +217,9 @@ export const usersReducer = createReducer(
volume: voiceState.volume ?? prev.volume, volume: voiceState.volume ?? prev.volume,
// Use explicit undefined check - if undefined is passed, clear the value // Use explicit undefined check - if undefined is passed, clear the value
roomId: voiceState.roomId !== undefined ? voiceState.roomId : prev.roomId, roomId: voiceState.roomId !== undefined ? voiceState.roomId : prev.roomId,
serverId: voiceState.serverId !== undefined ? voiceState.serverId : prev.serverId, serverId: voiceState.serverId !== undefined ? voiceState.serverId : prev.serverId
}, }
}, }
}, },
state state
); );
@@ -224,8 +228,9 @@ export const usersReducer = createReducer(
// Update screen share state // Update screen share state
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => { on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
const prev = state.entities[userId]?.screenShareState || { const prev = state.entities[userId]?.screenShareState || {
isSharing: false, isSharing: false
}; };
return usersAdapter.updateOne( return usersAdapter.updateOne(
{ {
id: userId, id: userId,
@@ -234,9 +239,9 @@ export const usersReducer = createReducer(
isSharing: screenShareState.isSharing ?? prev.isSharing, isSharing: screenShareState.isSharing ?? prev.isSharing,
streamId: screenShareState.streamId ?? prev.streamId, streamId: screenShareState.streamId ?? prev.streamId,
sourceId: screenShareState.sourceId ?? prev.sourceId, sourceId: screenShareState.sourceId ?? prev.sourceId,
sourceName: screenShareState.sourceName ?? prev.sourceName, sourceName: screenShareState.sourceName ?? prev.sourceName
}, }
}, }
}, },
state state
); );
@@ -250,9 +255,10 @@ export const usersReducer = createReducer(
// Clear users // Clear users
on(UsersActions.clearUsers, (state) => { on(UsersActions.clearUsers, (state) => {
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId); const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
return usersAdapter.removeMany(idsToRemove, { return usersAdapter.removeMany(idsToRemove, {
...state, ...state,
hostId: null, hostId: null
}); });
}), }),
@@ -260,11 +266,12 @@ export const usersReducer = createReducer(
on(UsersActions.updateHost, (state, { userId }) => { on(UsersActions.updateHost, (state, { userId }) => {
// Update the old host's role to member // Update the old host's role to member
let newState = state; let newState = state;
if (state.hostId && state.hostId !== userId) { if (state.hostId && state.hostId !== userId) {
newState = usersAdapter.updateOne( newState = usersAdapter.updateOne(
{ {
id: state.hostId, id: state.hostId,
changes: { role: 'member' }, changes: { role: 'member' }
}, },
state state
); );
@@ -274,11 +281,11 @@ export const usersReducer = createReducer(
return usersAdapter.updateOne( return usersAdapter.updateOne(
{ {
id: userId, id: userId,
changes: { role: 'host' }, changes: { role: 'host' }
}, },
{ {
...newState, ...newState,
hostId: userId, hostId: userId
} }
); );
}) })

View File

@@ -1,5 +1,5 @@
export const environment = { export const environment = {
production: true, production: true,
serverUrl: 'https://your-server.com/api', serverUrl: 'https://your-server.com/api',
signalingUrl: 'wss://your-server.com/signaling', signalingUrl: 'wss://your-server.com/signaling'
}; };

View File

@@ -1,5 +1,5 @@
export const environment = { export const environment = {
production: false, production: false,
serverUrl: 'http://localhost:3000/api', serverUrl: 'http://localhost:3000/api',
signalingUrl: 'ws://localhost:3001', signalingUrl: 'ws://localhost:3001'
}; };

View File

@@ -3,12 +3,18 @@ import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
import mermaid from 'mermaid'; import mermaid from 'mermaid';
declare global {
interface Window {
mermaid: typeof mermaid;
}
}
// Expose mermaid globally for ngx-remark's MermaidComponent // Expose mermaid globally for ngx-remark's MermaidComponent
(window as any)['mermaid'] = mermaid; window.mermaid = mermaid;
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
securityLevel: 'loose', securityLevel: 'loose',
theme: 'dark', theme: 'dark'
}); });
bootstrapApplication(App, appConfig) bootstrapApplication(App, appConfig)

View File

@@ -4,6 +4,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"rootDir": "./src",
"types": [] "types": []
}, },
"include": [ "include": [