Add eslint
This commit is contained in:
218
eslint.config.js
Normal file
218
eslint.config.js
Normal 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.
|
||||
@@ -28,7 +28,8 @@
|
||||
"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: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": {
|
||||
"printWidth": 100,
|
||||
@@ -78,16 +79,22 @@
|
||||
"@angular/build": "^21.0.4",
|
||||
"@angular/cli": "^21.0.4",
|
||||
"@angular/compiler-cli": "^21.0.0",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "^21.2.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
|
||||
48
server/dist/db.d.ts
vendored
48
server/dist/db.d.ts
vendored
@@ -1,43 +1,43 @@
|
||||
export declare function initDB(): Promise<void>;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
export declare function getUserByUsername(username: string): Promise<AuthUser | null>;
|
||||
export declare function getUserById(id: string): Promise<AuthUser | null>;
|
||||
export declare function createUser(user: AuthUser): Promise<void>;
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
tags: string[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
tags: string[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
export declare function getAllPublicServers(): Promise<ServerInfo[]>;
|
||||
export declare function getServerById(id: string): Promise<ServerInfo | null>;
|
||||
export declare function upsertServer(server: ServerInfo): Promise<void>;
|
||||
export declare function deleteServer(id: string): Promise<void>;
|
||||
export interface JoinRequest {
|
||||
id: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
userPublicKey: string;
|
||||
displayName: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: number;
|
||||
id: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
userPublicKey: string;
|
||||
displayName: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: number;
|
||||
}
|
||||
export declare function createJoinRequest(req: JoinRequest): Promise<void>;
|
||||
export declare function getJoinRequestById(id: string): Promise<JoinRequest | null>;
|
||||
export declare function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]>;
|
||||
export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void>;
|
||||
export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise<void>;
|
||||
//# sourceMappingURL=db.d.ts.map
|
||||
// # sourceMappingURL=db.d.ts.map
|
||||
|
||||
2
server/dist/index.d.ts
vendored
2
server/dist/index.d.ts
vendored
@@ -1,2 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
// # sourceMappingURL=index.d.ts.map
|
||||
|
||||
@@ -7,19 +7,23 @@ const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
|
||||
|
||||
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 db: any | null = null;
|
||||
|
||||
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') });
|
||||
ensureDataDir();
|
||||
|
||||
if (fs.existsSync(DB_FILE)) {
|
||||
const fileBuffer = fs.readFileSync(DB_FILE);
|
||||
|
||||
db = new SQL.Database(new Uint8Array(fileBuffer));
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
@@ -68,9 +72,12 @@ export async function initDB(): Promise<void> {
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
if (!db) return;
|
||||
if (!db)
|
||||
return;
|
||||
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
|
||||
fs.writeFileSync(DB_FILE, buffer);
|
||||
}
|
||||
|
||||
@@ -87,46 +94,61 @@ export interface AuthUser {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
stmt.bind([username]);
|
||||
let row: AuthUser | null = null;
|
||||
|
||||
if (stmt.step()) {
|
||||
const r = stmt.getAsObject() as any;
|
||||
|
||||
row = {
|
||||
id: String(r.id),
|
||||
username: String(r.username),
|
||||
passwordHash: String(r.passwordHash),
|
||||
displayName: String(r.displayName),
|
||||
createdAt: Number(r.createdAt),
|
||||
createdAt: Number(r.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
stmt.bind([id]);
|
||||
let row: AuthUser | null = null;
|
||||
|
||||
if (stmt.step()) {
|
||||
const r = stmt.getAsObject() as any;
|
||||
|
||||
row = {
|
||||
id: String(r.id),
|
||||
username: String(r.username),
|
||||
passwordHash: String(r.passwordHash),
|
||||
displayName: String(r.displayName),
|
||||
createdAt: Number(r.createdAt),
|
||||
createdAt: Number(r.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
|
||||
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 (?, ?, ?, ?, ?)');
|
||||
|
||||
stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
@@ -163,39 +185,51 @@ function rowToServer(r: any): ServerInfo {
|
||||
currentUsers: Number(r.currentUsers),
|
||||
tags: JSON.parse(String(r.tags || '[]')),
|
||||
createdAt: Number(r.createdAt),
|
||||
lastSeen: Number(r.lastSeen),
|
||||
lastSeen: Number(r.lastSeen)
|
||||
};
|
||||
}
|
||||
|
||||
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 results: ServerInfo[] = [];
|
||||
|
||||
while (stmt.step()) {
|
||||
results.push(rowToServer(stmt.getAsObject()));
|
||||
}
|
||||
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
stmt.bind([id]);
|
||||
let row: ServerInfo | null = null;
|
||||
|
||||
if (stmt.step()) {
|
||||
row = rowToServer(stmt.getAsObject());
|
||||
}
|
||||
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function upsertServer(server: ServerInfo): Promise<void> {
|
||||
if (!db) await initDB();
|
||||
if (!db)
|
||||
await initDB();
|
||||
|
||||
const stmt = db!.prepare(`
|
||||
INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.bind([
|
||||
server.id,
|
||||
server.name,
|
||||
@@ -207,7 +241,7 @@ export async function upsertServer(server: ServerInfo): Promise<void> {
|
||||
server.currentUsers,
|
||||
JSON.stringify(server.tags),
|
||||
server.createdAt,
|
||||
server.lastSeen,
|
||||
server.lastSeen
|
||||
]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
@@ -215,13 +249,17 @@ export async function upsertServer(server: ServerInfo): 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 = ?');
|
||||
|
||||
stmt.bind([id]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
// Also clean up related join requests
|
||||
const jStmt = db!.prepare('DELETE FROM join_requests WHERE serverId = ?');
|
||||
|
||||
jStmt.bind([id]);
|
||||
jStmt.step();
|
||||
jStmt.free();
|
||||
@@ -250,16 +288,19 @@ function rowToJoinRequest(r: any): JoinRequest {
|
||||
userPublicKey: String(r.userPublicKey),
|
||||
displayName: String(r.displayName),
|
||||
status: String(r.status) as JoinRequest['status'],
|
||||
createdAt: Number(r.createdAt),
|
||||
createdAt: Number(r.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
export async function createJoinRequest(req: JoinRequest): Promise<void> {
|
||||
if (!db) await initDB();
|
||||
if (!db)
|
||||
await initDB();
|
||||
|
||||
const stmt = db!.prepare(`
|
||||
INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
@@ -267,32 +308,45 @@ export async function createJoinRequest(req: JoinRequest): Promise<void> {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
stmt.bind([id]);
|
||||
let row: JoinRequest | null = null;
|
||||
|
||||
if (stmt.step()) {
|
||||
row = rowToJoinRequest(stmt.getAsObject());
|
||||
}
|
||||
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
|
||||
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 = ?');
|
||||
|
||||
stmt.bind([serverId, 'pending']);
|
||||
const results: JoinRequest[] = [];
|
||||
|
||||
while (stmt.step()) {
|
||||
results.push(rowToJoinRequest(stmt.getAsObject()));
|
||||
}
|
||||
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
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 = ?');
|
||||
|
||||
stmt.bind([status, id]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
@@ -300,9 +354,12 @@ export async function updateJoinRequestStatus(id: string, status: JoinRequest['s
|
||||
}
|
||||
|
||||
export async function deleteStaleJoinRequests(maxAgeMs: number): Promise<void> {
|
||||
if (!db) await initDB();
|
||||
if (!db)
|
||||
await initDB();
|
||||
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
const stmt = db!.prepare('DELETE FROM join_requests WHERE createdAt < ?');
|
||||
|
||||
stmt.bind([cutoff]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
|
||||
@@ -23,8 +23,8 @@ app.use(express.json());
|
||||
interface ConnectedUser {
|
||||
oderId: string;
|
||||
ws: WebSocket;
|
||||
serverIds: Set<string>; // all servers the user is a member of
|
||||
viewedServerId?: string; // currently viewed/active server
|
||||
serverIds: Set<string>; // all servers the user is a member of
|
||||
viewedServerId?: string; // currently viewed/active server
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@@ -46,21 +46,23 @@ import {
|
||||
updateJoinRequestStatus,
|
||||
deleteStaleJoinRequests,
|
||||
ServerInfo,
|
||||
JoinRequest,
|
||||
JoinRequest
|
||||
} 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
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const allServers = await getAllPublicServers();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
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) => {
|
||||
try {
|
||||
const url = String(req.query.url || '');
|
||||
|
||||
if (!/^https?:\/\//i.test(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 timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -87,12 +91,14 @@ app.get('/api/image-proxy', async (req, res) => {
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (!contentType.toLowerCase().startsWith('image/')) {
|
||||
return res.status(415).json({ error: 'Unsupported content type' });
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit
|
||||
|
||||
if (arrayBuffer.byteLength > MAX_BYTES) {
|
||||
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') {
|
||||
return res.status(504).json({ error: 'Timeout fetching image' });
|
||||
}
|
||||
|
||||
console.error('Image proxy error:', err);
|
||||
res.status(502).json({ error: 'Failed to fetch image' });
|
||||
}
|
||||
@@ -112,10 +119,17 @@ app.get('/api/image-proxy', async (req, res) => {
|
||||
// Auth
|
||||
app.post('/api/users/register', async (req, res) => {
|
||||
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);
|
||||
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() };
|
||||
|
||||
await createUser(user);
|
||||
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) => {
|
||||
const { username, password } = req.body;
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -137,20 +154,25 @@ app.get('/api/servers', async (req, res) => {
|
||||
.filter(s => {
|
||||
if (q) {
|
||||
const query = String(q).toLowerCase();
|
||||
|
||||
return s.name.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.filter(s => {
|
||||
if (tags) {
|
||||
const tagList = String(tags).split(',');
|
||||
|
||||
return tagList.some(t => s.tags.includes(t));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = results.length;
|
||||
|
||||
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
|
||||
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
|
||||
@@ -176,7 +198,7 @@ app.post('/api/servers', async (req, res) => {
|
||||
currentUsers: 0,
|
||||
tags: tags ?? [],
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
|
||||
await upsertServer(server);
|
||||
@@ -187,8 +209,8 @@ app.post('/api/servers', async (req, res) => {
|
||||
app.put('/api/servers/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, ...updates } = req.body;
|
||||
|
||||
const server = await getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
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() };
|
||||
|
||||
await upsertServer(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) => {
|
||||
const { id } = req.params;
|
||||
const { currentUsers } = req.body;
|
||||
|
||||
const server = await getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
server.lastSeen = Date.now();
|
||||
|
||||
if (typeof currentUsers === 'number') {
|
||||
server.currentUsers = currentUsers;
|
||||
}
|
||||
|
||||
await upsertServer(server);
|
||||
|
||||
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) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId } = req.body;
|
||||
|
||||
const server = await getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
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) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, userPublicKey, displayName } = req.body;
|
||||
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
@@ -257,7 +282,7 @@ app.post('/api/servers/:id/join', async (req, res) => {
|
||||
userPublicKey,
|
||||
displayName,
|
||||
status: server.isPrivate ? 'pending' : 'approved',
|
||||
createdAt: Date.now(),
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
await createJoinRequest(request);
|
||||
@@ -266,7 +291,7 @@ app.post('/api/servers/:id/join', async (req, res) => {
|
||||
if (server.isPrivate) {
|
||||
notifyServerOwner(server.ownerId, {
|
||||
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) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { ownerId } = req.query;
|
||||
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
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);
|
||||
|
||||
res.json({ requests });
|
||||
});
|
||||
|
||||
@@ -295,13 +321,14 @@ app.get('/api/servers/:id/requests', async (req, res) => {
|
||||
app.put('/api/requests/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, status } = req.body;
|
||||
|
||||
const request = await getJoinRequestById(id);
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
|
||||
const server = await getServerById(request.serverId);
|
||||
|
||||
if (!server || server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
@@ -312,7 +339,7 @@ app.put('/api/requests/:id', async (req, res) => {
|
||||
// Notify the requester
|
||||
notifyUser(request.userId, {
|
||||
type: 'request_update',
|
||||
request: updated,
|
||||
request: updated
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
@@ -325,16 +352,19 @@ function buildServer() {
|
||||
const certDir = path.resolve(__dirname, '..', '..', '.certs');
|
||||
const certFile = path.join(certDir, 'localhost.crt');
|
||||
const keyFile = path.join(certDir, 'localhost.key');
|
||||
|
||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||
console.error(`SSL=true but certs not found in ${certDir}`);
|
||||
console.error('Run ./generate-cert.sh first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return createHttpsServer(
|
||||
{ cert: fs.readFileSync(certFile), key: fs.readFileSync(keyFile) },
|
||||
app,
|
||||
app
|
||||
);
|
||||
}
|
||||
|
||||
return createHttpServer(app);
|
||||
}
|
||||
|
||||
@@ -343,11 +373,13 @@ const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
const connectionId = uuidv4();
|
||||
|
||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
handleWebSocketMessage(connectionId, message);
|
||||
} catch (err) {
|
||||
console.error('Invalid WebSocket message:', err);
|
||||
@@ -356,6 +388,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
ws.on('close', () => {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (user) {
|
||||
// Notify all servers the user was a member of
|
||||
user.serverIds.forEach((sid) => {
|
||||
@@ -363,10 +396,11 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
});
|
||||
}
|
||||
|
||||
connectedUsers.delete(connectionId);
|
||||
});
|
||||
|
||||
@@ -376,7 +410,9 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
function handleWebSocketMessage(connectionId: string, message: any): void {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
if (!user) return;
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
switch (message.type) {
|
||||
case 'identify':
|
||||
@@ -391,6 +427,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
||||
case 'join_server': {
|
||||
const sid = message.serverId;
|
||||
const isNew = !user.serverIds.has(sid);
|
||||
|
||||
user.serverIds.add(sid);
|
||||
user.viewedServerId = sid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
@@ -405,7 +442,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'server_users',
|
||||
serverId: sid,
|
||||
users: usersInServer,
|
||||
users: usersInServer
|
||||
}));
|
||||
|
||||
// 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',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName || 'Anonymous',
|
||||
serverId: sid,
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'view_server': {
|
||||
// Just switch the viewed server without joining/leaving
|
||||
const viewSid = message.serverId;
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
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({
|
||||
type: 'server_users',
|
||||
serverId: viewSid,
|
||||
users: viewUsers,
|
||||
users: viewUsers
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'leave_server': {
|
||||
const leaveSid = message.serverId || user.viewedServerId;
|
||||
|
||||
if (leaveSid) {
|
||||
user.serverIds.delete(leaveSid);
|
||||
|
||||
if (user.viewedServerId === leaveSid) {
|
||||
user.viewedServerId = undefined;
|
||||
}
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
broadcastToServer(leaveSid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName || 'Anonymous',
|
||||
serverId: leaveSid,
|
||||
serverId: leaveSid
|
||||
}, user.oderId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -465,21 +508,24 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
||||
// Forward signaling messages to specific peer
|
||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`);
|
||||
const targetUser = findUserByUserId(message.targetUserId);
|
||||
|
||||
if (targetUser) {
|
||||
targetUser.ws.send(JSON.stringify({
|
||||
...message,
|
||||
fromUserId: user.oderId,
|
||||
fromUserId: user.oderId
|
||||
}));
|
||||
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
|
||||
} else {
|
||||
console.log(`Target user ${message.targetUserId} not found. Connected users:`,
|
||||
Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName })));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'chat_message': {
|
||||
// Broadcast chat message to all users in the server
|
||||
const chatSid = message.serverId || user.viewedServerId;
|
||||
|
||||
if (chatSid && user.serverIds.has(chatSid)) {
|
||||
broadcastToServer(chatSid, {
|
||||
type: 'chat_message',
|
||||
@@ -487,23 +533,26 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
||||
message: message.message,
|
||||
senderId: user.oderId,
|
||||
senderName: user.displayName,
|
||||
timestamp: Date.now(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'typing': {
|
||||
// Broadcast typing indicator
|
||||
const typingSid = message.serverId || user.viewedServerId;
|
||||
|
||||
if (typingSid && user.serverIds.has(typingSid)) {
|
||||
broadcastToServer(typingSid, {
|
||||
type: 'user_typing',
|
||||
serverId: typingSid,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
displayName: user.displayName
|
||||
}, user.oderId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -524,6 +573,7 @@ function broadcastToServer(serverId: string, message: any, excludeOderId?: strin
|
||||
|
||||
function notifyServerOwner(ownerId: string, message: any): void {
|
||||
const owner = findUserByUserId(ownerId);
|
||||
|
||||
if (owner) {
|
||||
owner.ws.send(JSON.stringify(message));
|
||||
}
|
||||
@@ -531,6 +581,7 @@ function notifyServerOwner(ownerId: string, message: any): void {
|
||||
|
||||
function notifyUser(oderId: string, message: any): void {
|
||||
const user = findUserByUserId(oderId);
|
||||
|
||||
if (user) {
|
||||
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)
|
||||
setInterval(() => {
|
||||
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);
|
||||
|
||||
@@ -551,11 +602,13 @@ initDB().then(() => {
|
||||
server.listen(PORT, () => {
|
||||
const proto = USE_SSL ? 'https' : 'http';
|
||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
||||
|
||||
console.log(`🚀 MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
||||
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -24,14 +24,14 @@ export const appConfig: ApplicationConfig = {
|
||||
provideStore({
|
||||
messages: messagesReducer,
|
||||
users: usersReducer,
|
||||
rooms: roomsReducer,
|
||||
rooms: roomsReducer
|
||||
}),
|
||||
provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]),
|
||||
provideStoreDevtools({
|
||||
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
||||
logOnly: !isDevMode(),
|
||||
autoPause: true,
|
||||
trace: false,
|
||||
}),
|
||||
],
|
||||
trace: false
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
@@ -5,33 +5,33 @@ export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'search',
|
||||
pathMatch: 'full',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/login/login.component').then((module) => module.LoginComponent),
|
||||
import('./features/auth/login/login.component').then((module) => module.LoginComponent)
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent),
|
||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
loadComponent: () =>
|
||||
import('./features/server-search/server-search.component').then(
|
||||
(module) => module.ServerSearchComponent
|
||||
),
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'room/:roomId',
|
||||
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',
|
||||
loadComponent: () =>
|
||||
import('./features/settings/settings.component').then((module) => module.SettingsComponent),
|
||||
},
|
||||
import('./features/settings/settings.component').then((module) => module.SettingsComponent)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */
|
||||
import { Component, OnInit, inject, HostListener } from '@angular/core';
|
||||
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -18,7 +19,7 @@ import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import {
|
||||
ROOM_URL_PATTERN,
|
||||
STORAGE_KEY_CURRENT_USER_ID,
|
||||
STORAGE_KEY_LAST_VISITED_ROUTE,
|
||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||
} from './core/constants';
|
||||
|
||||
/**
|
||||
@@ -35,10 +36,10 @@ import {
|
||||
ServersRailComponent,
|
||||
TitleBarComponent,
|
||||
FloatingVoiceControlsComponent,
|
||||
SettingsModalComponent,
|
||||
SettingsModalComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit {
|
||||
private databaseService = inject(DatabaseService);
|
||||
@@ -64,6 +65,7 @@ export class App implements OnInit {
|
||||
// Initial time sync with active server
|
||||
try {
|
||||
const apiBase = this.servers.getApiBaseUrl();
|
||||
|
||||
await this.timeSync.syncWithEndpoint(apiBase);
|
||||
} catch {}
|
||||
|
||||
@@ -75,14 +77,17 @@ export class App implements OnInit {
|
||||
|
||||
// If not authenticated, redirect to login; else restore last route
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
|
||||
if (!currentUserId) {
|
||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
||||
this.router.navigate(['/login']).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||
|
||||
if (last && typeof last === 'string') {
|
||||
const current = this.router.url;
|
||||
|
||||
if (current === '/' || current === '/search') {
|
||||
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
|
||||
}
|
||||
@@ -93,6 +98,7 @@ export class App implements OnInit {
|
||||
this.router.events.subscribe((evt) => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
|
||||
// Store room route or search
|
||||
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
||||
|
||||
|
||||
@@ -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 { v4 as uuidv4 } from 'uuid';
|
||||
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. */
|
||||
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
||||
|
||||
/** Maximum file size (bytes) that is automatically saved to disk (Electron). */
|
||||
const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/**
|
||||
* EWMA smoothing weight for the *previous* speed estimate.
|
||||
* 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_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT;
|
||||
|
||||
/** Fallback MIME type when none is provided by the sender. */
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
|
||||
/** localStorage key used by the legacy attachment store (migration target). */
|
||||
const LEGACY_STORAGE_KEY = 'metoyou_attachments';
|
||||
|
||||
@@ -144,11 +141,13 @@ export class AttachmentService {
|
||||
* {@link AttachmentMeta} (local paths are scrubbed).
|
||||
*/
|
||||
getAttachmentMetasForMessages(
|
||||
messageIds: string[],
|
||||
messageIds: string[]
|
||||
): Record<string, AttachmentMeta[]> {
|
||||
const result: Record<string, AttachmentMeta[]> = {};
|
||||
|
||||
for (const messageId of messageIds) {
|
||||
const attachments = this.attachmentsByMessage.get(messageId);
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
result[messageId] = attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
@@ -158,11 +157,12 @@ export class AttachmentService {
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId,
|
||||
filePath: undefined, // never share local paths
|
||||
savedPath: undefined, // never share local paths
|
||||
filePath: undefined, // never share local paths
|
||||
savedPath: undefined // never share local paths
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -173,20 +173,24 @@ export class AttachmentService {
|
||||
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
||||
*/
|
||||
registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
attachmentMap: Record<string, AttachmentMeta[]>
|
||||
): void {
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
||||
const existing = this.attachmentsByMessage.get(messageId) ?? [];
|
||||
|
||||
for (const meta of metas) {
|
||||
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
|
||||
|
||||
if (!alreadyKnown) {
|
||||
const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 };
|
||||
|
||||
existing.push(attachment);
|
||||
newAttachments.push(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
this.attachmentsByMessage.set(messageId, existing);
|
||||
}
|
||||
@@ -194,6 +198,7 @@ export class AttachmentService {
|
||||
|
||||
if (newAttachments.length > 0) {
|
||||
this.touch();
|
||||
|
||||
for (const attachment of newAttachments) {
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
}
|
||||
@@ -210,11 +215,14 @@ export class AttachmentService {
|
||||
*/
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
console.warn('[Attachments] No connected peers to request file from');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = this.buildRequestKey(messageId, attachment.id);
|
||||
|
||||
this.pendingRequests.set(requestKey, new Set());
|
||||
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
|
||||
}
|
||||
@@ -224,9 +232,13 @@ export class AttachmentService {
|
||||
*/
|
||||
handleFileNotFound(payload: any): void {
|
||||
const { messageId, fileId } = payload;
|
||||
if (!messageId || !fileId) return;
|
||||
|
||||
if (!messageId || !fileId)
|
||||
return;
|
||||
|
||||
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
||||
const attachment = attachments.find((entry) => entry.id === fileId);
|
||||
|
||||
this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
|
||||
}
|
||||
|
||||
@@ -260,7 +272,7 @@ export class AttachmentService {
|
||||
async publishAttachments(
|
||||
messageId: string,
|
||||
files: File[],
|
||||
uploaderPeerId?: string,
|
||||
uploaderPeerId?: string
|
||||
): Promise<void> {
|
||||
const attachments: Attachment[] = [];
|
||||
|
||||
@@ -275,8 +287,9 @@ export class AttachmentService {
|
||||
isImage: file.type.startsWith('image/'),
|
||||
uploaderPeerId,
|
||||
filePath: (file as any)?.path,
|
||||
available: false,
|
||||
available: false
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
|
||||
// Retain the original File so we can serve file-request later
|
||||
@@ -303,8 +316,8 @@ export class AttachmentService {
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId,
|
||||
},
|
||||
uploaderPeerId
|
||||
}
|
||||
} as any);
|
||||
|
||||
// Auto-stream small images
|
||||
@@ -314,6 +327,7 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
const existingList = this.attachmentsByMessage.get(messageId) ?? [];
|
||||
|
||||
this.attachmentsByMessage.set(messageId, [...existingList, ...attachments]);
|
||||
this.touch();
|
||||
|
||||
@@ -325,11 +339,15 @@ export class AttachmentService {
|
||||
/** Handle a `file-announce` event from a peer. */
|
||||
handleFileAnnounce(payload: any): void {
|
||||
const { messageId, file } = payload;
|
||||
if (!messageId || !file) return;
|
||||
|
||||
if (!messageId || !file)
|
||||
return;
|
||||
|
||||
const list = this.attachmentsByMessage.get(messageId) ?? [];
|
||||
const alreadyKnown = list.find((entry) => entry.id === file.id);
|
||||
if (alreadyKnown) return;
|
||||
|
||||
if (alreadyKnown)
|
||||
return;
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: file.id,
|
||||
@@ -340,8 +358,9 @@ export class AttachmentService {
|
||||
isImage: !!file.isImage,
|
||||
uploaderPeerId: file.uploaderPeerId,
|
||||
available: false,
|
||||
receivedBytes: 0,
|
||||
receivedBytes: 0
|
||||
};
|
||||
|
||||
list.push(attachment);
|
||||
this.attachmentsByMessage.set(messageId, list);
|
||||
this.touch();
|
||||
@@ -357,22 +376,27 @@ export class AttachmentService {
|
||||
*/
|
||||
handleFileChunk(payload: any): void {
|
||||
const { messageId, fileId, index, total, data } = payload;
|
||||
|
||||
if (
|
||||
!messageId || !fileId ||
|
||||
typeof index !== 'number' ||
|
||||
typeof total !== 'number' ||
|
||||
!data
|
||||
) return;
|
||||
)
|
||||
return;
|
||||
|
||||
const list = this.attachmentsByMessage.get(messageId) ?? [];
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
if (!attachment) return;
|
||||
|
||||
if (!attachment)
|
||||
return;
|
||||
|
||||
const decodedBytes = this.base64ToUint8Array(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
|
||||
// Initialise assembly buffer on first chunk
|
||||
let chunkBuffer = this.chunkBuffers.get(assemblyKey);
|
||||
|
||||
if (!chunkBuffer) {
|
||||
chunkBuffer = new Array(total);
|
||||
this.chunkBuffers.set(assemblyKey, chunkBuffer);
|
||||
@@ -388,14 +412,19 @@ export class AttachmentService {
|
||||
// Update progress stats
|
||||
const now = Date.now();
|
||||
const previousReceived = attachment.receivedBytes ?? 0;
|
||||
|
||||
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||
|
||||
if (!attachment.startedAtMs) attachment.startedAtMs = now;
|
||||
if (!attachment.lastUpdateMs) attachment.lastUpdateMs = now;
|
||||
if (!attachment.startedAtMs)
|
||||
attachment.startedAtMs = now;
|
||||
|
||||
if (!attachment.lastUpdateMs)
|
||||
attachment.lastUpdateMs = now;
|
||||
|
||||
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
|
||||
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
|
||||
const previousSpeed = attachment.speedBps ?? instantaneousBps;
|
||||
|
||||
attachment.speedBps =
|
||||
EWMA_PREVIOUS_WEIGHT * previousSpeed +
|
||||
EWMA_CURRENT_WEIGHT * instantaneousBps;
|
||||
@@ -405,10 +434,13 @@ export class AttachmentService {
|
||||
|
||||
// Check if assembly is complete
|
||||
const receivedChunkCount = this.chunkCounts.get(assemblyKey) ?? 0;
|
||||
|
||||
if (receivedChunkCount === total || (attachment.receivedBytes ?? 0) >= attachment.size) {
|
||||
const completeBuffer = this.chunkBuffers.get(assemblyKey);
|
||||
|
||||
if (completeBuffer && completeBuffer.every((part) => part instanceof ArrayBuffer)) {
|
||||
const blob = new Blob(completeBuffer, { type: attachment.mime });
|
||||
|
||||
attachment.available = true;
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -441,10 +473,13 @@ export class AttachmentService {
|
||||
*/
|
||||
async handleFileRequest(payload: any): Promise<void> {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
if (!messageId || !fileId || !fromPeerId) return;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
// 1. In-memory original
|
||||
const exactKey = `${messageId}:${fileId}`;
|
||||
|
||||
let originalFile = this.originalFiles.get(exactKey);
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
const appDataPath = await electronApi.getAppDataPath();
|
||||
|
||||
if (appDataPath) {
|
||||
const roomName = await this.resolveCurrentRoomName();
|
||||
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
|
||||
const diskPath = `${appDataPath}/server/${sanitisedRoom}/image/${attachment.filename}`;
|
||||
|
||||
if (await electronApi.fileExists(diskPath)) {
|
||||
await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, diskPath);
|
||||
return;
|
||||
@@ -508,6 +545,7 @@ export class AttachmentService {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], attachment.filename, { type: attachment.mime });
|
||||
|
||||
await this.streamFileToPeer(fromPeerId, messageId, fileId, file);
|
||||
return;
|
||||
} catch { /* fall through */ }
|
||||
@@ -517,7 +555,7 @@ export class AttachmentService {
|
||||
this.webrtc.sendToPeer(fromPeerId, {
|
||||
type: 'file-not-found',
|
||||
messageId,
|
||||
fileId,
|
||||
fileId
|
||||
} as any);
|
||||
}
|
||||
|
||||
@@ -527,11 +565,14 @@ export class AttachmentService {
|
||||
*/
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
const targetPeerId = attachment.uploaderPeerId;
|
||||
if (!targetPeerId) return;
|
||||
|
||||
if (!targetPeerId)
|
||||
return;
|
||||
|
||||
try {
|
||||
// Reset assembly state
|
||||
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||
|
||||
this.chunkBuffers.delete(assemblyKey);
|
||||
this.chunkCounts.delete(assemblyKey);
|
||||
|
||||
@@ -542,8 +583,10 @@ export class AttachmentService {
|
||||
|
||||
if (attachment.objectUrl) {
|
||||
try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ }
|
||||
|
||||
attachment.objectUrl = undefined;
|
||||
}
|
||||
|
||||
attachment.available = false;
|
||||
this.touch();
|
||||
|
||||
@@ -551,7 +594,7 @@ export class AttachmentService {
|
||||
this.webrtc.sendToPeer(targetPeerId, {
|
||||
type: 'file-cancel',
|
||||
messageId,
|
||||
fileId: attachment.id,
|
||||
fileId: attachment.id
|
||||
} as any);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
@@ -562,9 +605,12 @@ export class AttachmentService {
|
||||
*/
|
||||
handleFileCancel(payload: any): void {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
if (!messageId || !fileId || !fromPeerId) return;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
this.cancelledTransfers.add(
|
||||
this.buildTransferKey(messageId, fileId, fromPeerId),
|
||||
this.buildTransferKey(messageId, fileId, fromPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -576,7 +622,7 @@ export class AttachmentService {
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
targetPeerId: string,
|
||||
file: File,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
this.originalFiles.set(`${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. */
|
||||
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
|
||||
return this.cancelledTransfers.has(
|
||||
this.buildTransferKey(messageId, fileId, targetPeerId),
|
||||
this.buildTransferKey(messageId, fileId, targetPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -616,7 +662,7 @@ export class AttachmentService {
|
||||
private sendFileRequestToNextPeer(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
preferredPeerId?: string,
|
||||
preferredPeerId?: string
|
||||
): boolean {
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
@@ -624,6 +670,7 @@ export class AttachmentService {
|
||||
|
||||
// Pick the best untried peer: preferred first, then any
|
||||
let targetPeerId: string | undefined;
|
||||
|
||||
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
|
||||
targetPeerId = preferredPeerId;
|
||||
} else {
|
||||
@@ -641,7 +688,7 @@ export class AttachmentService {
|
||||
this.webrtc.sendToPeer(targetPeerId, {
|
||||
type: 'file-request',
|
||||
messageId,
|
||||
fileId,
|
||||
fileId
|
||||
} as any);
|
||||
return true;
|
||||
}
|
||||
@@ -650,9 +697,10 @@ export class AttachmentService {
|
||||
private async streamFileToPeers(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
file: File,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
@@ -667,7 +715,7 @@ export class AttachmentService {
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64,
|
||||
data: base64
|
||||
} as any);
|
||||
|
||||
offset += FILE_CHUNK_SIZE_BYTES;
|
||||
@@ -680,14 +728,16 @@ export class AttachmentService {
|
||||
targetPeerId: string,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
file: File,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
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 arrayBuffer = await slice.arrayBuffer();
|
||||
@@ -699,7 +749,7 @@ export class AttachmentService {
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64,
|
||||
data: base64
|
||||
} as any);
|
||||
|
||||
offset += FILE_CHUNK_SIZE_BYTES;
|
||||
@@ -715,7 +765,7 @@ export class AttachmentService {
|
||||
targetPeerId: string,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
diskPath: string,
|
||||
diskPath: string
|
||||
): Promise<void> {
|
||||
const electronApi = (window as any)?.electronAPI;
|
||||
const base64Full = await electronApi.readFile(diskPath);
|
||||
@@ -723,14 +773,15 @@ export class AttachmentService {
|
||||
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
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 end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES);
|
||||
const slice = fileBytes.subarray(start, end);
|
||||
const sliceBuffer = (slice.buffer as ArrayBuffer).slice(
|
||||
slice.byteOffset,
|
||||
slice.byteOffset + slice.byteLength,
|
||||
slice.byteOffset + slice.byteLength
|
||||
);
|
||||
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
||||
|
||||
@@ -740,7 +791,7 @@ export class AttachmentService {
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64Chunk,
|
||||
data: base64Chunk
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
@@ -753,7 +804,9 @@ export class AttachmentService {
|
||||
try {
|
||||
const electronApi = (window as any)?.electronAPI;
|
||||
const appDataPath: string | undefined = await electronApi?.getAppDataPath?.();
|
||||
if (!appDataPath) return;
|
||||
|
||||
if (!appDataPath)
|
||||
return;
|
||||
|
||||
const roomName = await this.resolveCurrentRoomName();
|
||||
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
|
||||
@@ -762,13 +815,14 @@ export class AttachmentService {
|
||||
: attachment.mime.startsWith('image/')
|
||||
? 'image'
|
||||
: 'files';
|
||||
|
||||
const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`;
|
||||
|
||||
await electronApi.ensureDir(directoryPath);
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||
const diskPath = `${directoryPath}/${attachment.filename}`;
|
||||
|
||||
await electronApi.writeFile(diskPath, base64);
|
||||
|
||||
attachment.savedPath = diskPath;
|
||||
@@ -779,14 +833,17 @@ export class AttachmentService {
|
||||
/** On startup, try loading previously saved files from disk (Electron). */
|
||||
private async tryLoadSavedFiles(): Promise<void> {
|
||||
const electronApi = (window as any)?.electronAPI;
|
||||
if (!electronApi?.fileExists || !electronApi?.readFile) return;
|
||||
|
||||
if (!electronApi?.fileExists || !electronApi?.readFile)
|
||||
return;
|
||||
|
||||
try {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [, attachments] of this.attachmentsByMessage) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.available) continue;
|
||||
if (attachment.available)
|
||||
continue;
|
||||
|
||||
// 1. Try savedPath (disk cache)
|
||||
if (attachment.savedPath) {
|
||||
@@ -805,10 +862,13 @@ export class AttachmentService {
|
||||
if (await electronApi.fileExists(attachment.filePath)) {
|
||||
this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.filePath));
|
||||
hasChanges = true;
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
const response = await fetch(attachment.objectUrl!);
|
||||
|
||||
void this.saveFileToDisk(attachment, await response.blob());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
@@ -816,7 +876,8 @@ export class AttachmentService {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) this.touch();
|
||||
if (hasChanges)
|
||||
this.touch();
|
||||
} catch { /* startup load is best-effort */ }
|
||||
}
|
||||
|
||||
@@ -827,15 +888,19 @@ export class AttachmentService {
|
||||
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
|
||||
const bytes = this.base64ToUint8Array(base64);
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
|
||||
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
attachment.available = true;
|
||||
const file = new File([blob], attachment.filename, { type: attachment.mime });
|
||||
|
||||
this.originalFiles.set(`${attachment.messageId}:${attachment.id}`, file);
|
||||
}
|
||||
|
||||
/** Save attachment metadata to the database (without file content). */
|
||||
private async persistAttachmentMeta(attachment: Attachment): Promise<void> {
|
||||
if (!this.database.isReady()) return;
|
||||
if (!this.database.isReady())
|
||||
return;
|
||||
|
||||
try {
|
||||
await this.database.saveAttachment({
|
||||
id: attachment.id,
|
||||
@@ -846,7 +911,7 @@ export class AttachmentService {
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId,
|
||||
filePath: attachment.filePath,
|
||||
savedPath: attachment.savedPath,
|
||||
savedPath: attachment.savedPath
|
||||
});
|
||||
} catch { /* persistence is best-effort */ }
|
||||
}
|
||||
@@ -856,12 +921,15 @@ export class AttachmentService {
|
||||
try {
|
||||
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
|
||||
for (const record of allRecords) {
|
||||
const attachment: Attachment = { ...record, available: false };
|
||||
const bucket = grouped.get(record.messageId) ?? [];
|
||||
|
||||
bucket.push(attachment);
|
||||
grouped.set(record.messageId, bucket);
|
||||
}
|
||||
|
||||
this.attachmentsByMessage = grouped;
|
||||
this.touch();
|
||||
} catch { /* load is best-effort */ }
|
||||
@@ -871,13 +939,18 @@ export class AttachmentService {
|
||||
private async migrateFromLocalStorage(): Promise<void> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
|
||||
|
||||
for (const meta of legacyRecords) {
|
||||
const existing = this.attachmentsByMessage.get(meta.messageId) ?? [];
|
||||
|
||||
if (!existing.find((entry) => entry.id === meta.id)) {
|
||||
const attachment: Attachment = { ...meta, available: false };
|
||||
|
||||
existing.push(attachment);
|
||||
this.attachmentsByMessage.set(meta.messageId, existing);
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
@@ -911,10 +984,13 @@ export class AttachmentService {
|
||||
/** Convert an ArrayBuffer to a base-64 string. */
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
for (let index = 0; index < bytes.byteLength; index++) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
@@ -922,9 +998,11 @@ export class AttachmentService {
|
||||
private base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
@@ -43,11 +44,12 @@ export class AuthService {
|
||||
|
||||
if (serverId) {
|
||||
endpoint = this.serverDirectory.servers().find(
|
||||
(server) => server.id === serverId,
|
||||
(server) => server.id === serverId
|
||||
);
|
||||
}
|
||||
|
||||
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
|
||||
|
||||
return activeEndpoint ? `${activeEndpoint.url}/api` : DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
@@ -68,10 +70,11 @@ export class AuthService {
|
||||
serverId?: string;
|
||||
}): Observable<LoginResponse> {
|
||||
const url = `${this.endpointFor(params.serverId)}/users/register`;
|
||||
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
displayName: params.displayName,
|
||||
displayName: params.displayName
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,9 +93,10 @@ export class AuthService {
|
||||
serverId?: string;
|
||||
}): Observable<LoginResponse> {
|
||||
const url = `${this.endpointFor(params.serverId)}/users/login`;
|
||||
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
password: params.password
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
|
||||
/** IndexedDB database name for the MetoYou application. */
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
|
||||
/** IndexedDB schema version — bump when adding/changing object stores. */
|
||||
const DATABASE_VERSION = 2;
|
||||
|
||||
/** Names of every object store used by the application. */
|
||||
const STORE_MESSAGES = 'messages';
|
||||
const STORE_USERS = 'users';
|
||||
@@ -15,7 +14,6 @@ const STORE_REACTIONS = 'reactions';
|
||||
const STORE_BANS = 'bans';
|
||||
const STORE_META = 'meta';
|
||||
const STORE_ATTACHMENTS = 'attachments';
|
||||
|
||||
/** All object store names, used when clearing the entire database. */
|
||||
const ALL_STORE_NAMES: string[] = [
|
||||
STORE_MESSAGES,
|
||||
@@ -24,7 +22,7 @@ const ALL_STORE_NAMES: string[] = [
|
||||
STORE_REACTIONS,
|
||||
STORE_BANS,
|
||||
STORE_ATTACHMENTS,
|
||||
STORE_META,
|
||||
STORE_META
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -41,7 +39,9 @@ export class BrowserDatabaseService {
|
||||
|
||||
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
|
||||
async initialize(): Promise<void> {
|
||||
if (this.database) return;
|
||||
if (this.database)
|
||||
return;
|
||||
|
||||
this.database = await this.openDatabase();
|
||||
}
|
||||
|
||||
@@ -59,8 +59,9 @@ export class BrowserDatabaseService {
|
||||
*/
|
||||
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||
const allRoomMessages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId,
|
||||
STORE_MESSAGES, 'roomId', roomId
|
||||
);
|
||||
|
||||
return allRoomMessages
|
||||
.sort((first, second) => first.timestamp - second.timestamp)
|
||||
.slice(offset, offset + limit);
|
||||
@@ -74,6 +75,7 @@ export class BrowserDatabaseService {
|
||||
/** Apply partial updates to an existing message. */
|
||||
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
|
||||
|
||||
if (existing) {
|
||||
await this.put(STORE_MESSAGES, { ...existing, ...updates });
|
||||
}
|
||||
@@ -87,12 +89,14 @@ export class BrowserDatabaseService {
|
||||
/** Remove every message belonging to a room. */
|
||||
async clearRoomMessages(roomId: string): Promise<void> {
|
||||
const messages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId,
|
||||
STORE_MESSAGES, 'roomId', roomId
|
||||
);
|
||||
const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite');
|
||||
|
||||
for (const message of messages) {
|
||||
transaction.objectStore(STORE_MESSAGES).delete(message.id);
|
||||
}
|
||||
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
@@ -102,11 +106,12 @@ export class BrowserDatabaseService {
|
||||
*/
|
||||
async saveReaction(reaction: Reaction): Promise<void> {
|
||||
const existing = await this.getAllFromIndex<Reaction>(
|
||||
STORE_REACTIONS, 'messageId', reaction.messageId,
|
||||
STORE_REACTIONS, 'messageId', reaction.messageId
|
||||
);
|
||||
const isDuplicate = existing.some(
|
||||
(entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji,
|
||||
(entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
await this.put(STORE_REACTIONS, reaction);
|
||||
}
|
||||
@@ -115,11 +120,12 @@ export class BrowserDatabaseService {
|
||||
/** Remove a specific reaction (identified by user + emoji + message). */
|
||||
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
||||
const reactions = await this.getAllFromIndex<Reaction>(
|
||||
STORE_REACTIONS, 'messageId', messageId,
|
||||
STORE_REACTIONS, 'messageId', messageId
|
||||
);
|
||||
const target = reactions.find(
|
||||
(entry) => entry.userId === userId && entry.emoji === emoji,
|
||||
(entry) => entry.userId === userId && entry.emoji === emoji
|
||||
);
|
||||
|
||||
if (target) {
|
||||
await this.deleteRecord(STORE_REACTIONS, target.id);
|
||||
}
|
||||
@@ -143,9 +149,12 @@ export class BrowserDatabaseService {
|
||||
/** Retrieve the last-authenticated ("current") user, or `null`. */
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -165,6 +174,7 @@ export class BrowserDatabaseService {
|
||||
/** Apply partial updates to an existing user. */
|
||||
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||
const existing = await this.get<User>(STORE_USERS, userId);
|
||||
|
||||
if (existing) {
|
||||
await this.put(STORE_USERS, { ...existing, ...updates });
|
||||
}
|
||||
@@ -194,6 +204,7 @@ export class BrowserDatabaseService {
|
||||
/** Apply partial updates to an existing room. */
|
||||
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||
const existing = await this.get<Room>(STORE_ROOMS, roomId);
|
||||
|
||||
if (existing) {
|
||||
await this.put(STORE_ROOMS, { ...existing, ...updates });
|
||||
}
|
||||
@@ -208,10 +219,11 @@ export class BrowserDatabaseService {
|
||||
async removeBan(oderId: string): Promise<void> {
|
||||
const allBans = await this.getAll<BanEntry>(STORE_BANS);
|
||||
const match = allBans.find((ban) => ban.oderId === oderId);
|
||||
|
||||
if (match) {
|
||||
await this.deleteRecord(
|
||||
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[]> {
|
||||
const allBans = await this.getAllFromIndex<BanEntry>(
|
||||
STORE_BANS, 'roomId', roomId,
|
||||
STORE_BANS, 'roomId', roomId
|
||||
);
|
||||
const now = Date.now();
|
||||
|
||||
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. */
|
||||
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||
const activeBans = await this.getBansForRoom(roomId);
|
||||
|
||||
return activeBans.some((ban) => ban.oderId === userId);
|
||||
}
|
||||
|
||||
@@ -255,23 +269,29 @@ export class BrowserDatabaseService {
|
||||
/** Delete all attachment records for a message. */
|
||||
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
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');
|
||||
|
||||
for (const attachment of attachments) {
|
||||
transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id);
|
||||
}
|
||||
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
/** Wipe every object store, removing all persisted data. */
|
||||
async clearAllData(): Promise<void> {
|
||||
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
|
||||
|
||||
for (const storeName of ALL_STORE_NAMES) {
|
||||
transaction.objectStore(storeName).clear();
|
||||
}
|
||||
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
@@ -292,27 +312,37 @@ export class BrowserDatabaseService {
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_MESSAGES)) {
|
||||
const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' });
|
||||
|
||||
messagesStore.createIndex('roomId', 'roomId', { unique: false });
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_USERS)) {
|
||||
database.createObjectStore(STORE_USERS, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_ROOMS)) {
|
||||
database.createObjectStore(STORE_ROOMS, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_REACTIONS)) {
|
||||
const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' });
|
||||
|
||||
reactionsStore.createIndex('messageId', 'messageId', { unique: false });
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_BANS)) {
|
||||
const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' });
|
||||
|
||||
bansStore.createIndex('roomId', 'roomId', { unique: false });
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_META)) {
|
||||
database.createObjectStore(STORE_META, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) {
|
||||
const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||
|
||||
attachmentsStore.createIndex('messageId', 'messageId', { unique: false });
|
||||
}
|
||||
};
|
||||
@@ -325,7 +355,7 @@ export class BrowserDatabaseService {
|
||||
/** Create an IndexedDB transaction on one or more stores. */
|
||||
private createTransaction(
|
||||
stores: string | string[],
|
||||
mode: IDBTransactionMode = 'readonly',
|
||||
mode: IDBTransactionMode = 'readonly'
|
||||
): IDBTransaction {
|
||||
return this.database!.transaction(stores, mode);
|
||||
}
|
||||
@@ -343,6 +373,7 @@ export class BrowserDatabaseService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName);
|
||||
const request = transaction.objectStore(storeName).get(key);
|
||||
|
||||
request.onsuccess = () => resolve(request.result as T | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -353,6 +384,7 @@ export class BrowserDatabaseService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName);
|
||||
const request = transaction.objectStore(storeName).getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result as T[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -362,12 +394,13 @@ export class BrowserDatabaseService {
|
||||
private getAllFromIndex<T>(
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
key: IDBValidKey,
|
||||
key: IDBValidKey
|
||||
): Promise<T[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName);
|
||||
const index = transaction.objectStore(storeName).index(indexName);
|
||||
const request = index.getAll(key);
|
||||
|
||||
request.onsuccess = () => resolve(request.result as T[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -377,6 +410,7 @@ export class BrowserDatabaseService {
|
||||
private put(storeName: string, value: any): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
|
||||
transaction.objectStore(storeName).put(value);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
@@ -387,6 +421,7 @@ export class BrowserDatabaseService {
|
||||
private deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
|
||||
transaction.objectStore(storeName).delete(key);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@@ -20,7 +20,9 @@ export class ElectronDatabaseService {
|
||||
|
||||
/** Initialise the SQLite database via the main-process IPC bridge. */
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialised) return;
|
||||
if (this.isInitialised)
|
||||
return;
|
||||
|
||||
await this.api.initialize();
|
||||
this.isInitialised = true;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export class ExternalLinkService {
|
||||
|
||||
/** Open a URL externally. Only http/https URLs are allowed. */
|
||||
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) {
|
||||
(window as any).electronAPI?.openExternal(url);
|
||||
@@ -28,20 +29,28 @@ export class ExternalLinkService {
|
||||
*/
|
||||
handleClick(evt: MouseEvent): boolean {
|
||||
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
|
||||
if (!href) return false;
|
||||
|
||||
if (!href)
|
||||
return false;
|
||||
|
||||
// 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
|
||||
const rawAttr = target.getAttribute('href');
|
||||
if (rawAttr?.startsWith('#')) return false;
|
||||
|
||||
if (rawAttr?.startsWith('#'))
|
||||
return false;
|
||||
|
||||
// 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.stopPropagation();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
@@ -8,18 +9,15 @@ import { Injectable, signal } from '@angular/core';
|
||||
export enum AppSound {
|
||||
Joining = 'joining',
|
||||
Leave = 'leave',
|
||||
Notification = 'notification',
|
||||
Notification = 'notification'
|
||||
}
|
||||
|
||||
/** Path prefix for audio assets (served from the `assets/audio/` folder). */
|
||||
const AUDIO_BASE = '/assets/audio';
|
||||
|
||||
/** File extension used for all sound-effect assets. */
|
||||
const AUDIO_EXT = 'wav';
|
||||
|
||||
/** localStorage key for persisting notification volume. */
|
||||
const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume';
|
||||
|
||||
/** Default notification volume (0 – 1). */
|
||||
const DEFAULT_VOLUME = 0.2;
|
||||
|
||||
@@ -49,6 +47,7 @@ export class NotificationAudioService {
|
||||
private preload(): void {
|
||||
for (const sound of Object.values(AppSound)) {
|
||||
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`);
|
||||
|
||||
audio.preload = 'auto';
|
||||
this.cache.set(sound, audio);
|
||||
}
|
||||
@@ -58,11 +57,15 @@ export class NotificationAudioService {
|
||||
private loadVolume(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME);
|
||||
|
||||
if (raw !== null) {
|
||||
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 {}
|
||||
|
||||
return DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
@@ -73,7 +76,9 @@ export class NotificationAudioService {
|
||||
*/
|
||||
setNotificationVolume(volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
|
||||
this.notificationVolume.set(clamped);
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_NOTIFICATION_VOLUME, String(clamped));
|
||||
} catch {}
|
||||
@@ -91,13 +96,18 @@ export class NotificationAudioService {
|
||||
*/
|
||||
play(sound: AppSound, volumeOverride?: number): void {
|
||||
const cached = this.cache.get(sound);
|
||||
if (!cached) return;
|
||||
|
||||
if (!cached)
|
||||
return;
|
||||
|
||||
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.
|
||||
const clone = cached.cloneNode(true) as HTMLAudioElement;
|
||||
|
||||
clone.volume = Math.max(0, Math.min(1, vol));
|
||||
clone.play().catch(() => {
|
||||
/* swallow autoplay errors */
|
||||
|
||||
@@ -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 { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, throwError, forkJoin } from 'rxjs';
|
||||
@@ -27,7 +28,6 @@ export interface ServerEndpoint {
|
||||
|
||||
/** localStorage key that persists the user's configured endpoints. */
|
||||
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
|
||||
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -38,8 +38,10 @@ const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
function buildDefaultServerUrl(): string {
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
|
||||
|
||||
return `${protocol}://localhost:3001`;
|
||||
}
|
||||
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
||||
url: buildDefaultServerUrl(),
|
||||
isActive: 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. */
|
||||
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) {
|
||||
@@ -92,8 +94,9 @@ export class ServerDirectoryService {
|
||||
url: sanitisedUrl,
|
||||
isActive: false,
|
||||
isDefault: false,
|
||||
status: 'unknown',
|
||||
status: 'unknown'
|
||||
};
|
||||
|
||||
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
@@ -106,17 +109,23 @@ export class ServerDirectoryService {
|
||||
removeServer(endpointId: string): void {
|
||||
const endpoints = this._servers();
|
||||
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
|
||||
if (target?.isDefault) return;
|
||||
|
||||
if (target?.isDefault)
|
||||
return;
|
||||
|
||||
const wasActive = target?.isActive;
|
||||
|
||||
this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId));
|
||||
|
||||
if (wasActive) {
|
||||
this._servers.update((list) => {
|
||||
if (list.length > 0) list[0].isActive = true;
|
||||
if (list.length > 0)
|
||||
list[0].isActive = true;
|
||||
|
||||
return [...list];
|
||||
});
|
||||
}
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
@@ -125,8 +134,8 @@ export class ServerDirectoryService {
|
||||
this._servers.update((endpoints) =>
|
||||
endpoints.map((endpoint) => ({
|
||||
...endpoint,
|
||||
isActive: endpoint.id === endpointId,
|
||||
})),
|
||||
isActive: endpoint.id === endpointId
|
||||
}))
|
||||
);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
@@ -135,12 +144,12 @@ export class ServerDirectoryService {
|
||||
updateServerStatus(
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number,
|
||||
latency?: number
|
||||
): void {
|
||||
this._servers.update((endpoints) =>
|
||||
endpoints.map((endpoint) =>
|
||||
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint,
|
||||
),
|
||||
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint
|
||||
)
|
||||
);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
@@ -158,7 +167,9 @@ export class ServerDirectoryService {
|
||||
*/
|
||||
async testServer(endpointId: string): Promise<boolean> {
|
||||
const endpoint = this._servers().find((entry) => entry.id === endpointId);
|
||||
if (!endpoint) return false;
|
||||
|
||||
if (!endpoint)
|
||||
return false;
|
||||
|
||||
this.updateServerStatus(endpointId, 'checking');
|
||||
const startTime = Date.now();
|
||||
@@ -166,7 +177,7 @@ export class ServerDirectoryService {
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
@@ -174,6 +185,7 @@ export class ServerDirectoryService {
|
||||
this.updateServerStatus(endpointId, 'online', latency);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.updateServerStatus(endpointId, 'offline');
|
||||
return false;
|
||||
} catch {
|
||||
@@ -181,14 +193,16 @@ export class ServerDirectoryService {
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}/api/servers`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
this.updateServerStatus(endpointId, 'online', latency);
|
||||
return true;
|
||||
}
|
||||
} catch { /* both checks failed */ }
|
||||
|
||||
this.updateServerStatus(endpointId, 'offline');
|
||||
return false;
|
||||
}
|
||||
@@ -197,6 +211,7 @@ export class ServerDirectoryService {
|
||||
/** Probe all configured endpoints in parallel. */
|
||||
async testAllServers(): Promise<void> {
|
||||
const endpoints = this._servers();
|
||||
|
||||
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. */
|
||||
getWebSocketUrl(): string {
|
||||
const active = this.activeServer();
|
||||
|
||||
if (!active) {
|
||||
const protocol = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
|
||||
|
||||
return `${protocol}://localhost:3001`;
|
||||
}
|
||||
|
||||
return active.url.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
@@ -224,6 +242,7 @@ export class ServerDirectoryService {
|
||||
if (this.shouldSearchAllServers) {
|
||||
return this.searchAllEndpoints(query);
|
||||
}
|
||||
|
||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
||||
}
|
||||
|
||||
@@ -232,6 +251,7 @@ export class ServerDirectoryService {
|
||||
if (this.shouldSearchAllServers) {
|
||||
return this.getAllServersFromAllEndpoints();
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
@@ -239,7 +259,7 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,13 +271,13 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Register a new server listing in the directory. */
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string },
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string }
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
|
||||
@@ -265,14 +285,14 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Update an existing server listing. */
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo>,
|
||||
updates: Partial<ServerInfo>
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.patch<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
|
||||
@@ -280,7 +300,7 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,7 +312,7 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,24 +324,24 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Send a join request for a server and receive the signaling URL. */
|
||||
requestJoin(
|
||||
request: JoinRequest,
|
||||
request: JoinRequest
|
||||
): Observable<{ success: boolean; signalingUrl?: string }> {
|
||||
return this.http
|
||||
.post<{ success: boolean; signalingUrl?: string }>(
|
||||
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
|
||||
request,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -333,7 +353,7 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,7 +365,7 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,7 +377,7 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,19 +388,24 @@ export class ServerDirectoryService {
|
||||
private buildApiBaseUrl(): string {
|
||||
const active = this.activeServer();
|
||||
const rawUrl = active ? active.url : buildDefaultServerUrl();
|
||||
|
||||
let base = rawUrl.replace(/\/+$/, '');
|
||||
|
||||
if (base.toLowerCase().endsWith('/api')) {
|
||||
base = base.slice(0, -4);
|
||||
}
|
||||
|
||||
return `${base}/api`;
|
||||
}
|
||||
|
||||
/** Strip trailing slashes and `/api` suffix from a URL. */
|
||||
private sanitiseUrl(rawUrl: string): string {
|
||||
let cleaned = rawUrl.trim().replace(/\/+$/, '');
|
||||
|
||||
if (cleaned.toLowerCase().endsWith('/api')) {
|
||||
cleaned = cleaned.slice(0, -4);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -389,18 +414,21 @@ export class ServerDirectoryService {
|
||||
* response shapes from the directory API.
|
||||
*/
|
||||
private unwrapServersResponse(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
|
||||
): ServerInfo[] {
|
||||
if (Array.isArray(response)) return response;
|
||||
if (Array.isArray(response))
|
||||
return response;
|
||||
|
||||
return response.servers ?? [];
|
||||
}
|
||||
|
||||
/** Search a single endpoint for servers matching a query. */
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: string,
|
||||
apiBaseUrl: string
|
||||
): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
@@ -408,14 +436,14 @@ export class ServerDirectoryService {
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Fan-out search across all non-offline endpoints, deduplicating results. */
|
||||
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this._servers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline',
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
@@ -428,22 +456,22 @@ export class ServerDirectoryService {
|
||||
results.map((server) => ({
|
||||
...server,
|
||||
sourceId: endpoint.id,
|
||||
sourceName: endpoint.name,
|
||||
})),
|
||||
),
|
||||
),
|
||||
sourceName: endpoint.name
|
||||
}))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers)),
|
||||
map((servers) => this.deduplicateById(servers))
|
||||
);
|
||||
}
|
||||
|
||||
/** Retrieve all servers from all non-offline endpoints. */
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this._servers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline',
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
@@ -451,7 +479,7 @@ export class ServerDirectoryService {
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.unwrapServersResponse(response)),
|
||||
catchError(() => of([])),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,14 +489,15 @@ export class ServerDirectoryService {
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const results = this.unwrapServersResponse(response);
|
||||
|
||||
return results.map((server) => ({
|
||||
...server,
|
||||
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()));
|
||||
@@ -477,8 +506,11 @@ export class ServerDirectoryService {
|
||||
/** Remove duplicate servers (by `id`), keeping the first occurrence. */
|
||||
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
if (seen.has(item.id))
|
||||
return false;
|
||||
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
@@ -487,6 +519,7 @@ export class ServerDirectoryService {
|
||||
/** Load endpoints from localStorage, migrating protocol if needed. */
|
||||
private loadEndpoints(): void {
|
||||
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
|
||||
|
||||
if (!stored) {
|
||||
this.initialiseDefaultEndpoint();
|
||||
return;
|
||||
@@ -510,6 +543,7 @@ export class ServerDirectoryService {
|
||||
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
|
||||
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) };
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
});
|
||||
|
||||
@@ -523,6 +557,7 @@ export class ServerDirectoryService {
|
||||
/** Create and persist the built-in default endpoint. */
|
||||
private initialiseDefaultEndpoint(): void {
|
||||
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() };
|
||||
|
||||
this._servers.set([defaultEndpoint]);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
/** Default timeout (ms) for the NTP-style HTTP sync request. */
|
||||
@@ -43,6 +44,7 @@ export class TimeSyncService {
|
||||
*/
|
||||
setFromServerTime(serverTime: number, receiveTimestamp?: number): void {
|
||||
const observedAt = receiveTimestamp ?? Date.now();
|
||||
|
||||
this._offset.set(serverTime - observedAt);
|
||||
this.lastSyncTimestamp = Date.now();
|
||||
}
|
||||
@@ -65,21 +67,21 @@ export class TimeSyncService {
|
||||
*/
|
||||
async syncWithEndpoint(
|
||||
baseApiUrl: string,
|
||||
timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS,
|
||||
timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS
|
||||
): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const clientSendTime = Date.now();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const response = await fetch(`${baseApiUrl}/time`, {
|
||||
signal: controller.signal,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const clientReceiveTime = Date.now();
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) return;
|
||||
if (!response.ok)
|
||||
return;
|
||||
|
||||
const data = await response.json();
|
||||
const serverNow = Number(data?.now) || Date.now();
|
||||
|
||||
@@ -19,13 +19,12 @@
|
||||
import { Injectable, signal, computed, inject, OnDestroy, Signal } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
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 (0–1) above which a user counts as "speaking". */
|
||||
const SPEAKING_THRESHOLD = 0.015;
|
||||
|
||||
/** How many consecutive silent frames before we flip speaking → false. */
|
||||
const SILENT_FRAME_GRACE = 8;
|
||||
|
||||
/** FFT size for the AnalyserNode (smaller = cheaper). */
|
||||
const FFT_SIZE = 256;
|
||||
|
||||
@@ -73,13 +72,13 @@ export class VoiceActivityService implements OnDestroy {
|
||||
this.subs.push(
|
||||
this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => {
|
||||
this.trackStream(peerId, stream);
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
this.webrtc.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.untrackStream(peerId);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +113,9 @@ export class VoiceActivityService implements OnDestroy {
|
||||
*/
|
||||
isSpeaking(userId: string): Signal<boolean> {
|
||||
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
|
||||
// once the stream is tracked.
|
||||
@@ -127,7 +128,10 @@ export class VoiceActivityService implements OnDestroy {
|
||||
*/
|
||||
volume(userId: string): Signal<number> {
|
||||
const entry = this.tracked.get(userId);
|
||||
if (entry) return entry.volumeSignal.asReadonly();
|
||||
|
||||
if (entry)
|
||||
return entry.volumeSignal.asReadonly();
|
||||
|
||||
return computed(() => 0);
|
||||
}
|
||||
|
||||
@@ -141,14 +145,18 @@ export class VoiceActivityService implements OnDestroy {
|
||||
trackStream(id: string, stream: MediaStream): void {
|
||||
// If we already track this exact stream, skip.
|
||||
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.
|
||||
if (existing) this.disposeEntry(existing);
|
||||
if (existing)
|
||||
this.disposeEntry(existing);
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
|
||||
analyser.fftSize = FFT_SIZE;
|
||||
|
||||
source.connect(analyser);
|
||||
@@ -167,7 +175,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
volumeSignal,
|
||||
speakingSignal,
|
||||
silentFrames: 0,
|
||||
stream,
|
||||
stream
|
||||
});
|
||||
|
||||
// Ensure the poll loop is running.
|
||||
@@ -177,19 +185,25 @@ export class VoiceActivityService implements OnDestroy {
|
||||
/** Stop tracking and dispose resources for a given ID. */
|
||||
untrackStream(id: string): void {
|
||||
const entry = this.tracked.get(id);
|
||||
if (!entry) return;
|
||||
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
this.disposeEntry(entry);
|
||||
this.tracked.delete(id);
|
||||
this.publishSpeakingMap();
|
||||
|
||||
// Stop polling when nothing is tracked.
|
||||
if (this.tracked.size === 0) this.stopPolling();
|
||||
if (this.tracked.size === 0)
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
// ── Polling loop ────────────────────────────────────────────────
|
||||
|
||||
private ensurePolling(): void {
|
||||
if (this.animFrameId !== null) return;
|
||||
if (this.animFrameId !== null)
|
||||
return;
|
||||
|
||||
this.poll();
|
||||
}
|
||||
|
||||
@@ -214,23 +228,29 @@ export class VoiceActivityService implements OnDestroy {
|
||||
|
||||
// Compute RMS volume from time-domain data (values 0–255, centred at 128).
|
||||
let sumSquares = 0;
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const normalised = (dataArray[i] - 128) / 128;
|
||||
|
||||
sumSquares += normalised * normalised;
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
||||
|
||||
volumeSignal.set(rms);
|
||||
|
||||
const wasSpeaking = speakingSignal();
|
||||
|
||||
if (rms >= SPEAKING_THRESHOLD) {
|
||||
entry.silentFrames = 0;
|
||||
|
||||
if (!wasSpeaking) {
|
||||
speakingSignal.set(true);
|
||||
mapDirty = true;
|
||||
}
|
||||
} else {
|
||||
entry.silentFrames++;
|
||||
|
||||
if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) {
|
||||
speakingSignal.set(false);
|
||||
mapDirty = true;
|
||||
@@ -238,7 +258,8 @@ export class VoiceActivityService implements OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
if (mapDirty) this.publishSpeakingMap();
|
||||
if (mapDirty)
|
||||
this.publishSpeakingMap();
|
||||
|
||||
this.animFrameId = requestAnimationFrame(this.poll);
|
||||
};
|
||||
@@ -246,6 +267,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
/** Rebuild the public speaking-map signal from current entries. */
|
||||
private publishSpeakingMap(): void {
|
||||
const map = new Map<string, boolean>();
|
||||
|
||||
this.tracked.forEach((entry, id) => {
|
||||
map.set(id, entry.speakingSignal());
|
||||
});
|
||||
@@ -256,6 +278,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
|
||||
private disposeEntry(entry: TrackedStream): void {
|
||||
try { entry.source.disconnect(); } catch { /* already disconnected */ }
|
||||
|
||||
try { entry.ctx.close(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
VoiceLevelingManager,
|
||||
VoiceLevelingSettings,
|
||||
DEFAULT_VOICE_LEVELING_SETTINGS,
|
||||
DEFAULT_VOICE_LEVELING_SETTINGS
|
||||
} from './webrtc/voice-leveling.manager';
|
||||
import { WebRTCLogger } from './webrtc/webrtc-logger';
|
||||
import { STORAGE_KEY_VOICE_LEVELING_SETTINGS } from '../constants';
|
||||
@@ -71,10 +72,11 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
|
||||
/* ── Enabled-change callbacks ────────────────────────────────── */
|
||||
|
||||
private _enabledChangeCallbacks: Array<(enabled: boolean) => void> = [];
|
||||
private _enabledChangeCallbacks: ((enabled: boolean) => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
const logger = new WebRTCLogger(/* debugEnabled */ false);
|
||||
|
||||
this.manager = new VoiceLevelingManager(logger);
|
||||
|
||||
// Restore persisted settings
|
||||
@@ -101,6 +103,7 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
/** Set the target loudness in dBFS (−30 to −12). */
|
||||
setTargetDbfs(value: number): void {
|
||||
const clamped = Math.max(-30, Math.min(-12, value));
|
||||
|
||||
this._targetDbfs.set(clamped);
|
||||
this._pushAndPersist({ targetDbfs: clamped });
|
||||
}
|
||||
@@ -114,6 +117,7 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
/** Set the maximum gain boost in dB (3 to 20). */
|
||||
setMaxGainDb(value: number): void {
|
||||
const clamped = Math.max(3, Math.min(20, value));
|
||||
|
||||
this._maxGainDb.set(clamped);
|
||||
this._pushAndPersist({ maxGainDb: clamped });
|
||||
}
|
||||
@@ -186,9 +190,10 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
*/
|
||||
onEnabledChange(callback: (enabled: boolean) => void): () => void {
|
||||
this._enabledChangeCallbacks.push(callback);
|
||||
|
||||
return () => {
|
||||
this._enabledChangeCallbacks = this._enabledChangeCallbacks.filter(
|
||||
(cb) => cb !== callback,
|
||||
(cb) => cb !== callback
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -210,11 +215,12 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
strength: this._strength(),
|
||||
maxGainDb: this._maxGainDb(),
|
||||
speed: this._speed(),
|
||||
noiseGate: this._noiseGate(),
|
||||
noiseGate: this._noiseGate()
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_VOICE_LEVELING_SETTINGS,
|
||||
JSON.stringify(settings),
|
||||
JSON.stringify(settings)
|
||||
);
|
||||
} catch { /* localStorage unavailable — ignore */ }
|
||||
}
|
||||
@@ -223,19 +229,31 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
private _loadSettings(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_LEVELING_SETTINGS);
|
||||
if (!raw) return;
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const saved = JSON.parse(raw) as Partial<VoiceLevelingSettings>;
|
||||
|
||||
if (typeof saved.enabled === 'boolean') this._enabled.set(saved.enabled);
|
||||
if (typeof saved.targetDbfs === 'number') this._targetDbfs.set(saved.targetDbfs);
|
||||
if (typeof saved.enabled === 'boolean')
|
||||
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') {
|
||||
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') {
|
||||
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
|
||||
this.manager.updateSettings({
|
||||
@@ -244,7 +262,7 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
strength: this._strength(),
|
||||
maxGainDb: this._maxGainDb(),
|
||||
speed: this._speed(),
|
||||
noiseGate: this._noiseGate(),
|
||||
noiseGate: this._noiseGate()
|
||||
});
|
||||
} catch { /* corrupted data — use defaults */ }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -56,7 +57,7 @@ export class VoiceSessionService {
|
||||
* a different server.
|
||||
*/
|
||||
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 {
|
||||
const session = this._voiceSession();
|
||||
|
||||
if (!session) {
|
||||
this._isViewingVoiceServer.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this._isViewingVoiceServer.set(currentServerId === session.serverId);
|
||||
}
|
||||
|
||||
@@ -110,7 +113,9 @@ export class VoiceSessionService {
|
||||
*/
|
||||
navigateToVoiceServer(): void {
|
||||
const session = this._voiceSession();
|
||||
if (!session) return;
|
||||
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.viewServer({
|
||||
@@ -123,9 +128,9 @@ export class VoiceSessionService {
|
||||
createdAt: 0,
|
||||
userCount: 0,
|
||||
maxUsers: 50,
|
||||
icon: session.serverIcon,
|
||||
} as any,
|
||||
}),
|
||||
icon: session.serverIcon
|
||||
} as any
|
||||
})
|
||||
);
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* This file wires them together and exposes a public API that is
|
||||
* 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 { Observable, Subject } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -43,11 +44,11 @@ import {
|
||||
SIGNALING_TYPE_USER_LEFT,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_VOICE_STATE,
|
||||
P2P_TYPE_SCREEN_STATE,
|
||||
P2P_TYPE_SCREEN_STATE
|
||||
} from './webrtc';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class WebRTCService implements OnDestroy {
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
@@ -97,8 +98,12 @@ export class WebRTCService implements OnDestroy {
|
||||
readonly hasConnectionError = computed(() => this._hasConnectionError());
|
||||
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
|
||||
readonly shouldShowConnectionError = computed(() => {
|
||||
if (!this._hasConnectionError()) return false;
|
||||
if (this._isVoiceConnected() && this._connectedPeers().length > 0) return false;
|
||||
if (!this._hasConnectionError())
|
||||
return false;
|
||||
|
||||
if (this._isVoiceConnected() && this._connectedPeers().length > 0)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
/** Per-peer latency map (ms). Read via `peerLatencies()`. */
|
||||
@@ -135,7 +140,7 @@ export class WebRTCService implements OnDestroy {
|
||||
this.logger,
|
||||
() => this.lastIdentifyCredentials,
|
||||
() => this.lastJoinedServer,
|
||||
() => this.memberServerIds,
|
||||
() => this.memberServerIds
|
||||
);
|
||||
|
||||
this.peerManager = new PeerConnectionManager(this.logger, null!);
|
||||
@@ -152,7 +157,7 @@ export class WebRTCService implements OnDestroy {
|
||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
||||
getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials,
|
||||
getLocalPeerId: (): string => this._localPeerId(),
|
||||
isScreenSharingActive: (): boolean => this._isScreenSharing(),
|
||||
isScreenSharingActive: (): boolean => this._isScreenSharing()
|
||||
});
|
||||
|
||||
this.mediaManager.setCallbacks({
|
||||
@@ -162,7 +167,7 @@ export class WebRTCService implements OnDestroy {
|
||||
broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event),
|
||||
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
|
||||
getIdentifyDisplayName: (): string =>
|
||||
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME,
|
||||
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
|
||||
});
|
||||
|
||||
this.screenShareManager.setCallbacks({
|
||||
@@ -170,7 +175,7 @@ export class WebRTCService implements OnDestroy {
|
||||
this.peerManager.activePeerConnections,
|
||||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||||
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
|
||||
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates()
|
||||
});
|
||||
|
||||
this.wireManagerEvents();
|
||||
@@ -180,7 +185,10 @@ export class WebRTCService implements OnDestroy {
|
||||
// Signaling → connection status
|
||||
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
|
||||
this._isSignalingConnected.set(connected);
|
||||
if (connected) this._hasEverConnected.set(true);
|
||||
|
||||
if (connected)
|
||||
this._hasEverConnected.set(true);
|
||||
|
||||
this._hasConnectionError.set(!connected);
|
||||
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
|
||||
});
|
||||
@@ -193,7 +201,7 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
// Peer manager → connected peers signal
|
||||
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
|
||||
this._connectedPeers.set(peers),
|
||||
this._connectedPeers.set(peers)
|
||||
);
|
||||
|
||||
// Media manager → voice connected signal
|
||||
@@ -204,6 +212,7 @@ export class WebRTCService implements OnDestroy {
|
||||
// Peer manager → latency updates
|
||||
this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => {
|
||||
const next = new Map(this.peerManager.peerLatencies);
|
||||
|
||||
this._peerLatencies.set(next);
|
||||
});
|
||||
}
|
||||
@@ -215,23 +224,27 @@ export class WebRTCService implements OnDestroy {
|
||||
switch (message.type) {
|
||||
case SIGNALING_TYPE_CONNECTED:
|
||||
this.logger.info('Server connected', { oderId: message.oderId });
|
||||
|
||||
if (typeof message.serverTime === 'number') {
|
||||
this.timeSync.setFromServerTime(message.serverTime);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SIGNALING_TYPE_SERVER_USERS: {
|
||||
this.logger.info('Server users', {
|
||||
count: Array.isArray(message.users) ? message.users.length : 0,
|
||||
serverId: message.serverId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.users && Array.isArray(message.users)) {
|
||||
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 healthy = this.isPeerHealthy(existing);
|
||||
|
||||
if (existing && !healthy) {
|
||||
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
||||
this.peerManager.removePeer(user.oderId);
|
||||
@@ -240,23 +253,25 @@ export class WebRTCService implements OnDestroy {
|
||||
if (!healthy) {
|
||||
this.logger.info('Create peer connection to existing user', {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
this.peerManager.createPeerConnection(user.oderId, true);
|
||||
this.peerManager.createAndSendOffer(user.oderId);
|
||||
|
||||
if (message.serverId) {
|
||||
this.peerServerMap.set(user.oderId, message.serverId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SIGNALING_TYPE_USER_JOINED:
|
||||
this.logger.info('User joined', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
oderId: message.oderId
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -264,35 +279,42 @@ export class WebRTCService implements OnDestroy {
|
||||
this.logger.info('User left', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
serverId: message.serverId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.oderId) {
|
||||
this.peerManager.removePeer(message.oderId);
|
||||
this.peerServerMap.delete(message.oderId);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SIGNALING_TYPE_OFFER:
|
||||
if (message.fromUserId && message.payload?.sdp) {
|
||||
// Track inbound peer as belonging to our effective server
|
||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||
|
||||
if (offerEffectiveServer && !this.peerServerMap.has(message.fromUserId)) {
|
||||
this.peerServerMap.set(message.fromUserId, offerEffectiveServer);
|
||||
}
|
||||
|
||||
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SIGNALING_TYPE_ANSWER:
|
||||
if (message.fromUserId && message.payload?.sdp) {
|
||||
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||
if (message.fromUserId && message.payload?.candidate) {
|
||||
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -307,11 +329,13 @@ export class WebRTCService implements OnDestroy {
|
||||
*/
|
||||
private closePeersNotInServer(serverId: string): void {
|
||||
const peersToClose: string[] = [];
|
||||
|
||||
this.peerServerMap.forEach((peerServerId, peerId) => {
|
||||
if (peerServerId !== serverId) {
|
||||
peersToClose.push(peerId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const peerId of peersToClose) {
|
||||
this.logger.info('Closing peer from different server', { peerId, currentServer: serverId });
|
||||
this.peerManager.removePeer(peerId);
|
||||
@@ -326,7 +350,7 @@ export class WebRTCService implements OnDestroy {
|
||||
isDeafened: this._isDeafened(),
|
||||
isScreenSharing: this._isScreenSharing(),
|
||||
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)', {
|
||||
serverId,
|
||||
userId,
|
||||
voiceConnected: this._isVoiceConnected(),
|
||||
voiceConnected: this._isVoiceConnected()
|
||||
});
|
||||
} else {
|
||||
this.memberServerIds.add(serverId);
|
||||
@@ -429,7 +453,7 @@ export class WebRTCService implements OnDestroy {
|
||||
this.logger.info('Joined new server via switch', {
|
||||
serverId,
|
||||
userId,
|
||||
voiceConnected: this._isVoiceConnected(),
|
||||
voiceConnected: this._isVoiceConnected()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -447,9 +471,11 @@ export class WebRTCService implements OnDestroy {
|
||||
this.memberServerIds.delete(serverId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId });
|
||||
this.logger.info('Left server', { serverId });
|
||||
|
||||
if (this.memberServerIds.size === 0) {
|
||||
this.fullCleanup();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -534,6 +560,7 @@ export class WebRTCService implements OnDestroy {
|
||||
*/
|
||||
async enableVoice(): Promise<MediaStream> {
|
||||
const stream = await this.mediaManager.enableVoice();
|
||||
|
||||
this.syncMediaSignals();
|
||||
return stream;
|
||||
}
|
||||
@@ -630,6 +657,7 @@ export class WebRTCService implements OnDestroy {
|
||||
if (serverId) {
|
||||
this.voiceServerId = serverId;
|
||||
}
|
||||
|
||||
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
|
||||
}
|
||||
|
||||
@@ -644,8 +672,9 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param includeAudio - Whether to capture and mix system audio.
|
||||
* @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);
|
||||
|
||||
this._isScreenSharing.set(true);
|
||||
this._screenStreamSignal.set(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. */
|
||||
private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean {
|
||||
if (!peer) return false;
|
||||
if (!peer)
|
||||
return false;
|
||||
|
||||
const connState = peer.connection?.connectionState;
|
||||
const dcState = peer.dataChannel?.readyState;
|
||||
|
||||
return connState === 'connected' && dcState === 'open';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
* attaching/detaching audio tracks to peer connections, bitrate tuning,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
VOICE_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_VOICE_STATE,
|
||||
LatencyProfile,
|
||||
LatencyProfile
|
||||
} from './webrtc.constants';
|
||||
|
||||
/**
|
||||
@@ -82,7 +83,7 @@ export class MediaManager {
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: MediaManagerCallbacks,
|
||||
private callbacks: MediaManagerCallbacks
|
||||
) {
|
||||
this.noiseReduction = new NoiseReductionManager(logger);
|
||||
}
|
||||
@@ -152,21 +153,23 @@ export class MediaManager {
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
autoGainControl: true
|
||||
},
|
||||
video: false,
|
||||
video: false
|
||||
};
|
||||
|
||||
this.logger.info('getUserMedia constraints', mediaConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error(
|
||||
'navigator.mediaDevices is not available. ' +
|
||||
'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);
|
||||
|
||||
this.rawMicStream = stream;
|
||||
|
||||
// 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 = null;
|
||||
}
|
||||
|
||||
this.localMediaStream = null;
|
||||
|
||||
// Remove audio senders but keep connections alive
|
||||
this.callbacks.getActivePeers().forEach((peerData) => {
|
||||
const senders = peerData.connection.getSenders();
|
||||
|
||||
senders.forEach((sender) => {
|
||||
if (sender.track?.kind === TRACK_KIND_AUDIO) {
|
||||
peerData.connection.removeTrack(sender);
|
||||
@@ -250,6 +255,7 @@ export class MediaManager {
|
||||
if (this.localMediaStream) {
|
||||
const audioTracks = this.localMediaStream.getAudioTracks();
|
||||
const newMutedState = muted !== undefined ? muted : !this.isMicMuted;
|
||||
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !newMutedState;
|
||||
});
|
||||
@@ -284,23 +290,27 @@ export class MediaManager {
|
||||
'Noise reduction desired =',
|
||||
shouldEnable,
|
||||
'| worklet active =',
|
||||
this.noiseReduction.isEnabled,
|
||||
this.noiseReduction.isEnabled
|
||||
);
|
||||
|
||||
if (shouldEnable === this.noiseReduction.isEnabled) return;
|
||||
if (shouldEnable === this.noiseReduction.isEnabled)
|
||||
return;
|
||||
|
||||
if (shouldEnable) {
|
||||
if (!this.rawMicStream) {
|
||||
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;
|
||||
}
|
||||
|
||||
this.logger.info('Enabling noise reduction on raw mic stream');
|
||||
const cleanStream = await this.noiseReduction.enable(this.rawMicStream);
|
||||
|
||||
this.localMediaStream = cleanStream;
|
||||
} else {
|
||||
this.noiseReduction.disable();
|
||||
|
||||
if (this.rawMicStream) {
|
||||
this.localMediaStream = this.rawMicStream;
|
||||
}
|
||||
@@ -330,23 +340,29 @@ export class MediaManager {
|
||||
async setAudioBitrate(kbps: number): Promise<void> {
|
||||
const targetBps = Math.max(
|
||||
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) => {
|
||||
const sender =
|
||||
peerData.audioSender ||
|
||||
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;
|
||||
|
||||
try {
|
||||
params = sender.getParameters();
|
||||
} catch (error) {
|
||||
this.logger.warn('getParameters failed; skipping bitrate apply', error as any);
|
||||
return;
|
||||
}
|
||||
|
||||
params.encodings = params.encodings || [{}];
|
||||
params.encodings[0].maxBitrate = targetBps;
|
||||
|
||||
@@ -380,8 +396,11 @@ export class MediaManager {
|
||||
this.stopVoiceHeartbeat();
|
||||
|
||||
// Persist voice channel context so heartbeats and state snapshots include it
|
||||
if (roomId !== undefined) this.currentVoiceRoomId = roomId;
|
||||
if (serverId !== undefined) this.currentVoiceServerId = serverId;
|
||||
if (roomId !== undefined)
|
||||
this.currentVoiceRoomId = roomId;
|
||||
|
||||
if (serverId !== undefined)
|
||||
this.currentVoiceServerId = serverId;
|
||||
|
||||
this.voicePresenceTimer = setInterval(() => {
|
||||
if (this.isVoiceActive) {
|
||||
@@ -410,7 +429,9 @@ export class MediaManager {
|
||||
*/
|
||||
private bindLocalTracksToAllPeers(): void {
|
||||
const peers = this.callbacks.getActivePeers();
|
||||
if (!this.localMediaStream) return;
|
||||
|
||||
if (!this.localMediaStream)
|
||||
return;
|
||||
|
||||
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
|
||||
@@ -420,17 +441,20 @@ export class MediaManager {
|
||||
let audioSender =
|
||||
peerData.audioSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||
direction: TRANSCEIVER_SEND_RECV,
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
|
||||
// Restore direction after removeTrack (which sets it to recvonly)
|
||||
const audioTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === audioSender);
|
||||
|
||||
if (
|
||||
audioTransceiver &&
|
||||
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -449,16 +473,19 @@ export class MediaManager {
|
||||
let videoSender =
|
||||
peerData.videoSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
if (!videoSender) {
|
||||
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_SEND_RECV,
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
const videoTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === videoSender);
|
||||
|
||||
if (
|
||||
videoTransceiver &&
|
||||
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -481,6 +508,7 @@ export class MediaManager {
|
||||
private broadcastVoicePresence(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||
|
||||
this.callbacks.broadcastMessage({
|
||||
type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
@@ -490,8 +518,8 @@ export class MediaManager {
|
||||
isMuted: this.isMicMuted,
|
||||
isDeafened: this.isSelfDeafened,
|
||||
roomId: this.currentVoiceRoomId,
|
||||
serverId: this.currentVoiceServerId,
|
||||
},
|
||||
serverId: this.currentVoiceServerId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/**
|
||||
* 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. */
|
||||
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
|
||||
|
||||
/** RNNoise is trained on 48 kHz audio — the AudioContext must match. */
|
||||
const RNNOISE_SAMPLE_RATE = 48_000;
|
||||
|
||||
/**
|
||||
* Relative path (from the served application root) to the **bundled**
|
||||
* 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).
|
||||
*/
|
||||
disable(): void {
|
||||
if (!this._isEnabled) return;
|
||||
if (!this._isEnabled)
|
||||
return;
|
||||
|
||||
this.teardownGraph();
|
||||
this._isEnabled = false;
|
||||
this.logger.info('Noise reduction disabled');
|
||||
@@ -108,7 +109,8 @@ export class NoiseReductionManager {
|
||||
* @returns The denoised stream, or the raw stream on failure.
|
||||
*/
|
||||
async replaceInputStream(rawStream: MediaStream): Promise<MediaStream> {
|
||||
if (!this._isEnabled) return rawStream;
|
||||
if (!this._isEnabled)
|
||||
return rawStream;
|
||||
|
||||
try {
|
||||
// Disconnect old source but keep the rest of the graph alive
|
||||
@@ -176,11 +178,13 @@ export class NoiseReductionManager {
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
|
||||
try {
|
||||
this.workletNode?.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
|
||||
try {
|
||||
this.destinationNode?.disconnect();
|
||||
} catch {
|
||||
@@ -197,6 +201,7 @@ export class NoiseReductionManager {
|
||||
/* best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
this.audioContext = null;
|
||||
this.workletLoaded = false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length */
|
||||
/**
|
||||
* Creates and manages RTCPeerConnections, data channels,
|
||||
* offer/answer negotiation, ICE candidates, and P2P reconnection.
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
PeerData,
|
||||
DisconnectedPeerEntry,
|
||||
VoiceStateSnapshot,
|
||||
IdentifyCredentials,
|
||||
IdentifyCredentials
|
||||
} from './webrtc.types';
|
||||
import {
|
||||
ICE_SERVERS,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
SIGNALING_TYPE_OFFER,
|
||||
SIGNALING_TYPE_ANSWER,
|
||||
SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
DEFAULT_DISPLAY_NAME
|
||||
} from './webrtc.constants';
|
||||
|
||||
/**
|
||||
@@ -97,7 +98,7 @@ export class PeerConnectionManager {
|
||||
|
||||
constructor(
|
||||
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 });
|
||||
|
||||
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
|
||||
let dataChannel: RTCDataChannel | null = null;
|
||||
|
||||
// ICE candidates → signaling
|
||||
@@ -132,12 +134,12 @@ export class PeerConnectionManager {
|
||||
if (event.candidate) {
|
||||
this.logger.info('ICE candidate gathered', {
|
||||
remotePeerId,
|
||||
candidateType: (event.candidate as any)?.type,
|
||||
candidateType: (event.candidate as any)?.type
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
targetUserId: remotePeerId,
|
||||
payload: { candidate: event.candidate },
|
||||
payload: { candidate: event.candidate }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -146,7 +148,7 @@ export class PeerConnectionManager {
|
||||
connection.onconnectionstatechange = () => {
|
||||
this.logger.info('connectionstatechange', {
|
||||
remotePeerId,
|
||||
state: connection.connectionState,
|
||||
state: connection.connectionState
|
||||
});
|
||||
|
||||
switch (connection.connectionState) {
|
||||
@@ -175,12 +177,14 @@ export class PeerConnectionManager {
|
||||
connection.oniceconnectionstatechange = () => {
|
||||
this.logger.info('iceconnectionstatechange', {
|
||||
remotePeerId,
|
||||
state: connection.iceConnectionState,
|
||||
state: connection.iceConnectionState
|
||||
});
|
||||
};
|
||||
|
||||
connection.onsignalingstatechange = () => {
|
||||
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState });
|
||||
};
|
||||
|
||||
connection.onnegotiationneeded = () => {
|
||||
this.logger.info('negotiationneeded', { remotePeerId });
|
||||
};
|
||||
@@ -199,9 +203,11 @@ export class PeerConnectionManager {
|
||||
this.logger.info('Received data channel', { remotePeerId });
|
||||
dataChannel = event.channel;
|
||||
const existing = this.activePeerConnections.get(remotePeerId);
|
||||
|
||||
if (existing) {
|
||||
existing.dataChannel = dataChannel;
|
||||
}
|
||||
|
||||
this.setupDataChannel(dataChannel, remotePeerId);
|
||||
};
|
||||
}
|
||||
@@ -212,17 +218,18 @@ export class PeerConnectionManager {
|
||||
isInitiator,
|
||||
pendingIceCandidates: [],
|
||||
audioSender: undefined,
|
||||
videoSender: undefined,
|
||||
videoSender: undefined
|
||||
};
|
||||
|
||||
// Pre-create transceivers only for the initiator (offerer).
|
||||
if (isInitiator) {
|
||||
const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||
direction: TRANSCEIVER_SEND_RECV,
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
});
|
||||
const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_RECV_ONLY,
|
||||
direction: TRANSCEIVER_RECV_ONLY
|
||||
});
|
||||
|
||||
peerData.audioSender = audioTransceiver.sender;
|
||||
peerData.videoSender = videoTransceiver.sender;
|
||||
}
|
||||
@@ -231,6 +238,7 @@ export class PeerConnectionManager {
|
||||
|
||||
// Attach local stream to initiator
|
||||
const localStream = this.callbacks.getLocalMediaStream();
|
||||
|
||||
if (localStream && isInitiator) {
|
||||
this.logger.logStream(`localStream->${remotePeerId}`, localStream);
|
||||
localStream.getTracks().forEach((track) => {
|
||||
@@ -239,19 +247,23 @@ export class PeerConnectionManager {
|
||||
.replaceTrack(track)
|
||||
.then(() => this.logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
||||
.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) {
|
||||
peerData.videoSender
|
||||
.replaceTrack(track)
|
||||
.then(() => this.logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
||||
.catch((e) =>
|
||||
this.logger.error('video replaceTrack failed at createPeerConnection', e),
|
||||
this.logger.error('video replaceTrack failed at createPeerConnection', e)
|
||||
);
|
||||
} else {
|
||||
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> {
|
||||
const peerData = this.activePeerConnections.get(remotePeerId);
|
||||
if (!peerData) return;
|
||||
|
||||
if (!peerData)
|
||||
return;
|
||||
|
||||
try {
|
||||
const offer = await peerData.connection.createOffer();
|
||||
|
||||
await peerData.connection.setLocalDescription(offer);
|
||||
this.logger.info('Sending offer', {
|
||||
remotePeerId,
|
||||
type: offer.type,
|
||||
sdpLength: offer.sdp?.length,
|
||||
sdpLength: offer.sdp?.length
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_OFFER,
|
||||
targetUserId: remotePeerId,
|
||||
payload: { sdp: offer },
|
||||
payload: { sdp: offer }
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create offer', error);
|
||||
@@ -311,6 +326,7 @@ export class PeerConnectionManager {
|
||||
private enqueueNegotiation(peerId: string, task: () => Promise<void>): void {
|
||||
const prev = this.peerNegotiationQueue.get(peerId) ?? Promise.resolve();
|
||||
const next = prev.then(task, task); // always chain, even after rejection
|
||||
|
||||
this.peerNegotiationQueue.set(peerId, next);
|
||||
}
|
||||
|
||||
@@ -336,6 +352,7 @@ export class PeerConnectionManager {
|
||||
this.logger.info('Handling offer', { fromUserId });
|
||||
|
||||
let peerData = this.activePeerConnections.get(fromUserId);
|
||||
|
||||
if (!peerData) {
|
||||
peerData = this.createPeerConnection(fromUserId, false);
|
||||
}
|
||||
@@ -359,7 +376,7 @@ export class PeerConnectionManager {
|
||||
|
||||
this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId });
|
||||
await peerData.connection.setLocalDescription({
|
||||
type: 'rollback',
|
||||
type: 'rollback'
|
||||
} as RTCSessionDescriptionInit);
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -371,12 +388,15 @@ export class PeerConnectionManager {
|
||||
// Without this, the answerer's SDP answer defaults to recvonly for audio,
|
||||
// making the connection one-way (only the offerer's audio is heard).
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
|
||||
for (const transceiver of transceivers) {
|
||||
const receiverKind = transceiver.receiver.track?.kind;
|
||||
|
||||
if (receiverKind === TRACK_KIND_AUDIO) {
|
||||
if (!peerData.audioSender) {
|
||||
peerData.audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
// Promote to sendrecv so the SDP answer includes a send direction,
|
||||
// enabling bidirectional audio regardless of who initiated the connection.
|
||||
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
@@ -387,8 +407,10 @@ export class PeerConnectionManager {
|
||||
|
||||
// Attach local tracks (answerer side)
|
||||
const localStream = this.callbacks.getLocalMediaStream();
|
||||
|
||||
if (localStream) {
|
||||
this.logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
||||
|
||||
for (const track of localStream.getTracks()) {
|
||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||
await peerData.audioSender.replaceTrack(track);
|
||||
@@ -404,20 +426,22 @@ export class PeerConnectionManager {
|
||||
for (const candidate of peerData.pendingIceCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
|
||||
peerData.pendingIceCandidates = [];
|
||||
|
||||
const answer = await peerData.connection.createAnswer();
|
||||
|
||||
await peerData.connection.setLocalDescription(answer);
|
||||
|
||||
this.logger.info('Sending answer', {
|
||||
to: fromUserId,
|
||||
type: answer.type,
|
||||
sdpLength: answer.sdp?.length,
|
||||
sdpLength: answer.sdp?.length
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_ANSWER,
|
||||
targetUserId: fromUserId,
|
||||
payload: { sdp: answer },
|
||||
payload: { sdp: answer }
|
||||
});
|
||||
} catch (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> {
|
||||
this.logger.info('Handling answer', { fromUserId });
|
||||
const peerData = this.activePeerConnections.get(fromUserId);
|
||||
|
||||
if (!peerData) {
|
||||
this.logger.error('No peer for answer', new Error('Missing peer'), { fromUserId });
|
||||
return;
|
||||
@@ -450,13 +475,15 @@ export class PeerConnectionManager {
|
||||
try {
|
||||
if (peerData.connection.signalingState === 'have-local-offer') {
|
||||
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
|
||||
for (const candidate of peerData.pendingIceCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
|
||||
peerData.pendingIceCandidates = [];
|
||||
} else {
|
||||
this.logger.warn('Ignoring answer – wrong signaling state', {
|
||||
state: peerData.connection.signalingState,
|
||||
state: peerData.connection.signalingState
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -481,9 +508,10 @@ export class PeerConnectionManager {
|
||||
|
||||
private async doHandleIceCandidate(
|
||||
fromUserId: string,
|
||||
candidate: RTCIceCandidateInit,
|
||||
candidate: RTCIceCandidateInit
|
||||
): Promise<void> {
|
||||
let peerData = this.activePeerConnections.get(fromUserId);
|
||||
|
||||
if (!peerData) {
|
||||
this.logger.info('Creating peer for early ICE', { fromUserId });
|
||||
peerData = this.createPeerConnection(fromUserId, false);
|
||||
@@ -518,20 +546,23 @@ export class PeerConnectionManager {
|
||||
|
||||
private async doRenegotiate(peerId: string): Promise<void> {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
if (!peerData) return;
|
||||
|
||||
if (!peerData)
|
||||
return;
|
||||
|
||||
try {
|
||||
const offer = await peerData.connection.createOffer();
|
||||
|
||||
await peerData.connection.setLocalDescription(offer);
|
||||
this.logger.info('Renegotiate offer', {
|
||||
peerId,
|
||||
type: offer.type,
|
||||
sdpLength: offer.sdp?.length,
|
||||
sdpLength: offer.sdp?.length
|
||||
});
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_OFFER,
|
||||
targetUserId: peerId,
|
||||
payload: { sdp: offer },
|
||||
payload: { sdp: offer }
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to renegotiate', error);
|
||||
@@ -551,11 +582,13 @@ export class PeerConnectionManager {
|
||||
channel.onopen = () => {
|
||||
this.logger.info('Data channel open', { remotePeerId });
|
||||
this.sendCurrentStatesToChannel(channel, remotePeerId);
|
||||
|
||||
try {
|
||||
channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
this.startPingInterval(remotePeerId);
|
||||
};
|
||||
|
||||
@@ -570,6 +603,7 @@ export class PeerConnectionManager {
|
||||
channel.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
this.handlePeerMessage(remotePeerId, message);
|
||||
} catch (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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === P2P_TYPE_PONG) {
|
||||
const sent = this.pendingPings.get(peerId);
|
||||
|
||||
if (sent && typeof message.ts === 'number' && message.ts === sent) {
|
||||
const latencyMs = Math.round(performance.now() - sent);
|
||||
|
||||
this.peerLatencies.set(peerId, latencyMs);
|
||||
this.peerLatencyChanged$.next({ peerId, latencyMs });
|
||||
}
|
||||
|
||||
this.pendingPings.delete(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const enriched = { ...message, fromPeerId: peerId };
|
||||
|
||||
this.messageReceived$.next(enriched);
|
||||
}
|
||||
|
||||
/** Broadcast a ChatEvent to every peer with an open data channel. */
|
||||
broadcastMessage(event: ChatEvent): void {
|
||||
const data = JSON.stringify(event);
|
||||
|
||||
this.activePeerConnections.forEach((peerData, peerId) => {
|
||||
try {
|
||||
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||
@@ -640,10 +680,12 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Peer not connected – cannot send', { peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify(event));
|
||||
} catch (error) {
|
||||
@@ -662,6 +704,7 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Peer not connected – cannot send buffered', { peerId });
|
||||
return;
|
||||
@@ -682,6 +725,7 @@ export class PeerConnectionManager {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener('bufferedamountlow', handler as any, { once: true } as any);
|
||||
});
|
||||
}
|
||||
@@ -709,7 +753,7 @@ export class PeerConnectionManager {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive(),
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||
} as any);
|
||||
}
|
||||
|
||||
@@ -717,10 +761,11 @@ export class PeerConnectionManager {
|
||||
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Cannot send states – channel not open', {
|
||||
remotePeerId,
|
||||
state: channel.readyState,
|
||||
state: channel.readyState
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = this.callbacks.getIdentifyCredentials();
|
||||
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
|
||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
@@ -733,8 +778,8 @@ export class PeerConnectionManager {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive(),
|
||||
}),
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||
})
|
||||
);
|
||||
this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
|
||||
} catch (e) {
|
||||
@@ -754,7 +799,7 @@ export class PeerConnectionManager {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive(),
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||
} as any);
|
||||
}
|
||||
|
||||
@@ -762,13 +807,14 @@ export class PeerConnectionManager {
|
||||
const track = event.track;
|
||||
const settings =
|
||||
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
||||
|
||||
this.logger.info('Remote track', {
|
||||
remotePeerId,
|
||||
kind: track.kind,
|
||||
id: track.id,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
settings,
|
||||
settings
|
||||
});
|
||||
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
|
||||
|
||||
@@ -777,16 +823,17 @@ export class PeerConnectionManager {
|
||||
this.logger.info('Skipping inactive video track', {
|
||||
remotePeerId,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
readyState: track.readyState
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
.getTracks()
|
||||
.some((existingTrack) => existingTrack.id === track.id);
|
||||
|
||||
if (!trackAlreadyAdded) {
|
||||
try {
|
||||
compositeStream.addTrack(track);
|
||||
@@ -794,6 +841,7 @@ export class PeerConnectionManager {
|
||||
this.logger.warn('Failed to add track to composite stream', e as any);
|
||||
}
|
||||
}
|
||||
|
||||
this.remotePeerStreams.set(remotePeerId, compositeStream);
|
||||
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
|
||||
}
|
||||
@@ -805,8 +853,11 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
removePeer(peerId: string): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (peerData) {
|
||||
if (peerData.dataChannel) peerData.dataChannel.close();
|
||||
if (peerData.dataChannel)
|
||||
peerData.dataChannel.close();
|
||||
|
||||
peerData.connection.close();
|
||||
this.activePeerConnections.delete(peerId);
|
||||
this.peerNegotiationQueue.delete(peerId);
|
||||
@@ -823,7 +874,9 @@ export class PeerConnectionManager {
|
||||
this.clearAllPeerReconnectTimers();
|
||||
this.clearAllPingTimers();
|
||||
this.activePeerConnections.forEach((peerData) => {
|
||||
if (peerData.dataChannel) peerData.dataChannel.close();
|
||||
if (peerData.dataChannel)
|
||||
peerData.dataChannel.close();
|
||||
|
||||
peerData.connection.close();
|
||||
});
|
||||
this.activePeerConnections.clear();
|
||||
@@ -836,12 +889,13 @@ export class PeerConnectionManager {
|
||||
private trackDisconnectedPeer(peerId: string): void {
|
||||
this.disconnectedPeerTracker.set(peerId, {
|
||||
lastSeenTimestamp: Date.now(),
|
||||
reconnectAttempts: 0,
|
||||
reconnectAttempts: 0
|
||||
});
|
||||
}
|
||||
|
||||
private clearPeerReconnectTimer(peerId: string): void {
|
||||
const timer = this.peerReconnectTimers.get(peerId);
|
||||
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
this.peerReconnectTimers.delete(peerId);
|
||||
@@ -856,11 +910,14 @@ export class PeerConnectionManager {
|
||||
}
|
||||
|
||||
private schedulePeerReconnect(peerId: string): void {
|
||||
if (this.peerReconnectTimers.has(peerId)) return;
|
||||
if (this.peerReconnectTimers.has(peerId))
|
||||
return;
|
||||
|
||||
this.logger.info('Scheduling P2P reconnect', { peerId });
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const info = this.disconnectedPeerTracker.get(peerId);
|
||||
|
||||
if (!info) {
|
||||
this.clearPeerReconnectTimer(peerId);
|
||||
return;
|
||||
@@ -889,20 +946,24 @@ export class PeerConnectionManager {
|
||||
|
||||
private attemptPeerReconnect(peerId: string): void {
|
||||
const existing = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
existing.connection.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
this.activePeerConnections.delete(peerId);
|
||||
}
|
||||
|
||||
this.createPeerConnection(peerId, true);
|
||||
this.createAndSendOffer(peerId);
|
||||
}
|
||||
|
||||
private requestVoiceStateFromPeer(peerId: string): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST }));
|
||||
@@ -933,7 +994,7 @@ export class PeerConnectionManager {
|
||||
*/
|
||||
private removeFromConnectedPeers(peerId: string): void {
|
||||
this.connectedPeersList = this.connectedPeersList.filter(
|
||||
(connectedId) => connectedId !== peerId,
|
||||
(connectedId) => connectedId !== peerId
|
||||
);
|
||||
this.connectedPeersChanged$.next(this.connectedPeersList);
|
||||
}
|
||||
@@ -954,12 +1015,14 @@ export class PeerConnectionManager {
|
||||
// Send an immediate ping
|
||||
this.sendPing(peerId);
|
||||
const timer = setInterval(() => this.sendPing(peerId), PEER_PING_INTERVAL_MS);
|
||||
|
||||
this.peerPingTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
/** Stop the periodic ping for a specific peer. */
|
||||
private stopPingInterval(peerId: string): void {
|
||||
const timer = this.peerPingTimers.get(peerId);
|
||||
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
this.peerPingTimers.delete(peerId);
|
||||
@@ -975,10 +1038,14 @@ export class PeerConnectionManager {
|
||||
/** Send a single ping to a peer. */
|
||||
private sendPing(peerId: string): void {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN)
|
||||
return;
|
||||
|
||||
const ts = performance.now();
|
||||
|
||||
this.pendingPings.set(peerId, ts);
|
||||
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts }));
|
||||
} catch {
|
||||
|
||||
@@ -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,
|
||||
* mixed audio (screen + mic), and attaching screen tracks to peers.
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
SCREEN_SHARE_IDEAL_WIDTH,
|
||||
SCREEN_SHARE_IDEAL_HEIGHT,
|
||||
SCREEN_SHARE_IDEAL_FRAME_RATE,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||
} from './webrtc.constants';
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ export class ScreenShareManager {
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: ScreenShareCallbacks,
|
||||
private callbacks: ScreenShareCallbacks
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,7 @@ export class ScreenShareManager {
|
||||
* @returns The captured screen {@link MediaStream}.
|
||||
* @throws If both Electron and browser screen capture fail.
|
||||
*/
|
||||
async startScreenShare(includeSystemAudio: boolean = false): Promise<MediaStream> {
|
||||
async startScreenShare(includeSystemAudio = false): Promise<MediaStream> {
|
||||
try {
|
||||
this.logger.info('startScreenShare invoked', { includeSystemAudio });
|
||||
|
||||
@@ -78,19 +79,22 @@ export class ScreenShareManager {
|
||||
try {
|
||||
const sources = await (window as any).electronAPI.getSources();
|
||||
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
|
||||
|
||||
const electronConstraints: any = {
|
||||
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } },
|
||||
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }
|
||||
};
|
||||
|
||||
if (includeSystemAudio) {
|
||||
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } };
|
||||
} else {
|
||||
electronConstraints.audio = false;
|
||||
}
|
||||
|
||||
this.logger.info('desktopCapturer constraints', electronConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
|
||||
}
|
||||
|
||||
this.activeScreenStream = await navigator.mediaDevices.getUserMedia(electronConstraints);
|
||||
} catch (e) {
|
||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any);
|
||||
@@ -103,14 +107,17 @@ export class ScreenShareManager {
|
||||
video: {
|
||||
width: { ideal: SCREEN_SHARE_IDEAL_WIDTH },
|
||||
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;
|
||||
|
||||
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
||||
|
||||
if (!navigator.mediaDevices) {
|
||||
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
|
||||
}
|
||||
|
||||
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
|
||||
const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0];
|
||||
|
||||
if (screenVideoTrack) {
|
||||
screenVideoTrack.onended = () => {
|
||||
this.logger.warn('Screen video track ended');
|
||||
@@ -157,6 +165,7 @@ export class ScreenShareManager {
|
||||
// Clean up mixed audio
|
||||
if (this.combinedAudioStream) {
|
||||
try { this.combinedAudioStream.getTracks().forEach(track => track.stop()); } catch { /* ignore */ }
|
||||
|
||||
this.combinedAudioStream = null;
|
||||
}
|
||||
|
||||
@@ -164,26 +173,34 @@ export class ScreenShareManager {
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
|
||||
|
||||
if (videoTransceiver) {
|
||||
videoTransceiver.sender.replaceTrack(null).catch(() => {});
|
||||
|
||||
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
||||
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = undefined;
|
||||
peerData.screenAudioSender = undefined;
|
||||
|
||||
// Restore mic track
|
||||
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
|
||||
|
||||
if (micTrack) {
|
||||
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
audioSender.replaceTrack(micTrack).catch((error) => this.logger.error('Restore mic replaceTrack failed', error));
|
||||
}
|
||||
|
||||
this.callbacks.renegotiate(peerId);
|
||||
});
|
||||
}
|
||||
@@ -205,15 +222,18 @@ export class ScreenShareManager {
|
||||
if (!this.audioMixingContext && (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 screenAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([screenAudioTrack]));
|
||||
|
||||
screenAudioSource.connect(destination);
|
||||
|
||||
if (micAudioTrack) {
|
||||
const micAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([micAudioTrack]));
|
||||
|
||||
micAudioSource.connect(destination);
|
||||
this.logger.info('Mixed mic + screen audio together');
|
||||
}
|
||||
@@ -238,25 +258,33 @@ export class ScreenShareManager {
|
||||
*/
|
||||
private attachScreenTracksToPeers(includeSystemAudio: boolean): void {
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
if (!this.activeScreenStream) return;
|
||||
if (!this.activeScreenStream)
|
||||
return;
|
||||
|
||||
const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0];
|
||||
if (!screenVideoTrack) return;
|
||||
|
||||
if (!screenVideoTrack)
|
||||
return;
|
||||
|
||||
this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`);
|
||||
|
||||
// Use primary video sender/transceiver
|
||||
let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
if (!videoSender) {
|
||||
const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
videoSender = videoTransceiver.sender;
|
||||
peerData.videoSender = videoSender;
|
||||
} else {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(t => t.sender === videoSender);
|
||||
|
||||
if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = videoSender;
|
||||
videoSender.replaceTrack(screenVideoTrack)
|
||||
.then(() => this.logger.info('screen video replaceTrack ok', { peerId }))
|
||||
@@ -264,15 +292,20 @@ export class ScreenShareManager {
|
||||
|
||||
// Audio handling
|
||||
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
|
||||
|
||||
if (includeSystemAudio) {
|
||||
const combinedTrack = this.combinedAudioStream?.getAudioTracks()[0] || null;
|
||||
|
||||
if (combinedTrack) {
|
||||
this.logger.attachTrackDiagnostics(combinedTrack, `combinedAudio:${peerId}`);
|
||||
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
audioSender.replaceTrack(combinedTrack)
|
||||
.then(() => this.logger.info('screen audio(combined) replaceTrack ok', { peerId }))
|
||||
@@ -281,10 +314,13 @@ export class ScreenShareManager {
|
||||
} else if (micTrack) {
|
||||
this.logger.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`);
|
||||
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
|
||||
|
||||
audioSender = transceiver.sender;
|
||||
}
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
audioSender.replaceTrack(micTrack)
|
||||
.then(() => this.logger.info('screen audio(mic) replaceTrack ok', { peerId }))
|
||||
@@ -298,8 +334,10 @@ export class ScreenShareManager {
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.stopScreenShare();
|
||||
|
||||
if (this.audioMixingContext) {
|
||||
try { this.audioMixingContext.close(); } catch { /* ignore */ }
|
||||
|
||||
this.audioMixingContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
* including automatic reconnection and heartbeats.
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
STATE_HEARTBEAT_INTERVAL_MS,
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER
|
||||
} from './webrtc.constants';
|
||||
|
||||
export class SignalingManager {
|
||||
@@ -36,7 +37,7 @@ export class SignalingManager {
|
||||
private readonly logger: WebRTCLogger,
|
||||
private readonly getLastIdentify: () => IdentifyCredentials | 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. */
|
||||
@@ -63,6 +64,7 @@ export class SignalingManager {
|
||||
this.signalingWebSocket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
this.messageReceived$.next(message);
|
||||
} catch (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. */
|
||||
async ensureConnected(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Promise<boolean> {
|
||||
if (this.isSocketOpen()) return true;
|
||||
if (!this.lastSignalingUrl) return false;
|
||||
if (this.isSocketOpen())
|
||||
return true;
|
||||
|
||||
if (!this.lastSignalingUrl)
|
||||
return false;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) { settled = true; resolve(false); }
|
||||
}, timeoutMs);
|
||||
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
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'));
|
||||
return;
|
||||
}
|
||||
|
||||
const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() };
|
||||
|
||||
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'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.signalingWebSocket!.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
@@ -128,6 +137,7 @@ export class SignalingManager {
|
||||
close(): void {
|
||||
this.stopHeartbeat();
|
||||
this.clearReconnect();
|
||||
|
||||
if (this.signalingWebSocket) {
|
||||
this.signalingWebSocket.close();
|
||||
this.signalingWebSocket = null;
|
||||
@@ -147,21 +157,25 @@ export class SignalingManager {
|
||||
/** Re-identify and rejoin servers after a reconnect. */
|
||||
private reIdentifyAndRejoin(): void {
|
||||
const credentials = this.getLastIdentify();
|
||||
|
||||
if (credentials) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName });
|
||||
}
|
||||
|
||||
const memberIds = this.getMemberServerIds();
|
||||
|
||||
if (memberIds.size > 0) {
|
||||
memberIds.forEach((serverId) => {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
|
||||
});
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId });
|
||||
}
|
||||
} else {
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
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.
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return;
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl)
|
||||
return;
|
||||
|
||||
const delay = Math.min(
|
||||
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 = null;
|
||||
this.signalingReconnectAttempts++;
|
||||
this.logger.info('Attempting to reconnect to signaling...');
|
||||
this.connect(this.lastSignalingUrl!).subscribe({
|
||||
next: () => { this.signalingReconnectAttempts = 0; },
|
||||
error: () => { this.scheduleReconnect(); },
|
||||
error: () => { this.scheduleReconnect(); }
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
@@ -197,6 +214,7 @@ export class SignalingManager {
|
||||
clearTimeout(this.signalingReconnectTimer);
|
||||
this.signalingReconnectTimer = null;
|
||||
}
|
||||
|
||||
this.signalingReconnectAttempts = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable id-length, max-statements-per-line */
|
||||
/**
|
||||
* VoiceLevelingManager — manages per-speaker automatic gain control
|
||||
* pipelines for remote voice streams.
|
||||
@@ -70,7 +71,7 @@ export const DEFAULT_VOICE_LEVELING_SETTINGS: VoiceLevelingSettings = {
|
||||
strength: 'medium',
|
||||
maxGainDb: 12,
|
||||
speed: 'medium',
|
||||
noiseGate: false,
|
||||
noiseGate: false
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -89,7 +90,6 @@ interface SpeakerPipeline {
|
||||
|
||||
/** AudioWorklet module path (served from public/). */
|
||||
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
|
||||
|
||||
/** Processor name — must match `registerProcessor` in the worklet. */
|
||||
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
|
||||
|
||||
@@ -155,6 +155,7 @@ export class VoiceLevelingManager {
|
||||
async enable(peerId: string, stream: MediaStream): Promise<MediaStream> {
|
||||
// Reuse existing pipeline if it targets the same stream
|
||||
const existing = this.pipelines.get(peerId);
|
||||
|
||||
if (existing && existing.originalStream === stream) {
|
||||
return existing.destination.stream;
|
||||
}
|
||||
@@ -173,10 +174,11 @@ export class VoiceLevelingManager {
|
||||
|
||||
try {
|
||||
const pipeline = await this._buildPipeline(stream);
|
||||
|
||||
this.pipelines.set(peerId, pipeline);
|
||||
this.logger.info('VoiceLeveling: pipeline created', {
|
||||
peerId,
|
||||
fallback: pipeline.isFallback,
|
||||
fallback: pipeline.isFallback
|
||||
});
|
||||
return pipeline.destination.stream;
|
||||
} catch (err) {
|
||||
@@ -193,7 +195,10 @@ export class VoiceLevelingManager {
|
||||
*/
|
||||
disable(peerId: string): void {
|
||||
const pipeline = this.pipelines.get(peerId);
|
||||
if (!pipeline) return;
|
||||
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
this._disposePipeline(pipeline);
|
||||
this.pipelines.delete(peerId);
|
||||
this.logger.info('VoiceLeveling: pipeline removed', { peerId });
|
||||
@@ -207,15 +212,19 @@ export class VoiceLevelingManager {
|
||||
|
||||
setSpeakerVolume(peerId: string, volume: number): void {
|
||||
const pipeline = this.pipelines.get(peerId);
|
||||
if (!pipeline) return;
|
||||
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
pipeline.gainNode.gain.setValueAtTime(
|
||||
Math.max(0, Math.min(1, volume)),
|
||||
pipeline.ctx.currentTime,
|
||||
pipeline.ctx.currentTime
|
||||
);
|
||||
}
|
||||
|
||||
setMasterVolume(volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
|
||||
this.pipelines.forEach((pipeline) => {
|
||||
pipeline.gainNode.gain.setValueAtTime(clamped, pipeline.ctx.currentTime);
|
||||
});
|
||||
@@ -224,9 +233,11 @@ export class VoiceLevelingManager {
|
||||
/** Tear down all pipelines and release all resources. */
|
||||
destroy(): void {
|
||||
this.disableAll();
|
||||
|
||||
if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
|
||||
this._sharedCtx.close().catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
this._sharedCtx = null;
|
||||
this._workletLoaded = false;
|
||||
this._workletAvailable = null;
|
||||
@@ -243,9 +254,9 @@ export class VoiceLevelingManager {
|
||||
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
gainNode.gain.value = 1.0;
|
||||
const destination = ctx.createMediaStreamDestination();
|
||||
|
||||
const workletOk = await this._ensureWorkletLoaded(ctx);
|
||||
|
||||
if (workletOk) {
|
||||
@@ -263,7 +274,7 @@ export class VoiceLevelingManager {
|
||||
gainNode,
|
||||
destination,
|
||||
originalStream: stream,
|
||||
isFallback: false,
|
||||
isFallback: false
|
||||
};
|
||||
|
||||
this._pushSettingsToPipeline(pipeline);
|
||||
@@ -284,7 +295,7 @@ export class VoiceLevelingManager {
|
||||
gainNode,
|
||||
destination,
|
||||
originalStream: stream,
|
||||
isFallback: true,
|
||||
isFallback: true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -300,14 +311,18 @@ export class VoiceLevelingManager {
|
||||
if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
|
||||
return this._sharedCtx;
|
||||
}
|
||||
|
||||
this._sharedCtx = new AudioContext();
|
||||
this._workletLoaded = false;
|
||||
return this._sharedCtx;
|
||||
}
|
||||
|
||||
private async _ensureWorkletLoaded(ctx: AudioContext): Promise<boolean> {
|
||||
if (this._workletAvailable === false) return false;
|
||||
if (this._workletLoaded && this._workletAvailable === true) return true;
|
||||
if (this._workletAvailable === false)
|
||||
return false;
|
||||
|
||||
if (this._workletLoaded && this._workletAvailable === true)
|
||||
return true;
|
||||
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(WORKLET_MODULE_PATH);
|
||||
@@ -324,6 +339,7 @@ export class VoiceLevelingManager {
|
||||
|
||||
private _createFallbackCompressor(ctx: AudioContext): DynamicsCompressorNode {
|
||||
const compressor = ctx.createDynamicsCompressor();
|
||||
|
||||
compressor.threshold.setValueAtTime(-24, ctx.currentTime);
|
||||
compressor.knee.setValueAtTime(30, ctx.currentTime);
|
||||
compressor.ratio.setValueAtTime(3, ctx.currentTime);
|
||||
@@ -342,7 +358,7 @@ export class VoiceLevelingManager {
|
||||
maxGainDb: this._settings.maxGainDb,
|
||||
strength: this._settings.strength,
|
||||
speed: this._settings.speed,
|
||||
noiseGate: this._settings.noiseGate,
|
||||
noiseGate: this._settings.noiseGate
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -351,9 +367,13 @@ export class VoiceLevelingManager {
|
||||
|
||||
private _disposePipeline(pipeline: SpeakerPipeline): void {
|
||||
try { pipeline.source.disconnect(); } catch { /* already disconnected */ }
|
||||
|
||||
try { pipeline.workletNode?.disconnect(); } catch { /* ok */ }
|
||||
|
||||
try { pipeline.compressorNode?.disconnect(); } catch { /* ok */ }
|
||||
|
||||
try { pipeline.gainNode.disconnect(); } catch { /* ok */ }
|
||||
|
||||
try { pipeline.destination.disconnect(); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
/* eslint-disable max-statements-per-line */
|
||||
/**
|
||||
* Lightweight logging utility for the WebRTC subsystem.
|
||||
* All log lines are prefixed with `[WebRTC]`.
|
||||
*/
|
||||
export class WebRTCLogger {
|
||||
constructor(private readonly isEnabled: boolean = true) {}
|
||||
constructor(private readonly isEnabled = true) {}
|
||||
|
||||
/** Informational log (only when debug is enabled). */
|
||||
info(prefix: string, ...args: unknown[]): void {
|
||||
if (!this.isEnabled) return;
|
||||
if (!this.isEnabled)
|
||||
return;
|
||||
|
||||
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
/** Warning log (only when debug is enabled). */
|
||||
warn(prefix: string, ...args: unknown[]): void {
|
||||
if (!this.isEnabled) return;
|
||||
if (!this.isEnabled)
|
||||
return;
|
||||
|
||||
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
@@ -23,20 +28,22 @@ export class WebRTCLogger {
|
||||
name: (err as any)?.name,
|
||||
message: (err as any)?.message,
|
||||
stack: (err as any)?.stack,
|
||||
...extra,
|
||||
...extra
|
||||
};
|
||||
|
||||
try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
/** Attach lifecycle event listeners to a track for debugging. */
|
||||
attachTrackDiagnostics(track: MediaStreamTrack, label: string): void {
|
||||
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
||||
|
||||
this.info(`Track attached: ${label}`, {
|
||||
id: track.id,
|
||||
kind: track.kind,
|
||||
readyState: track.readyState,
|
||||
contentHint: track.contentHint,
|
||||
settings,
|
||||
settings
|
||||
});
|
||||
|
||||
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}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
|
||||
this.info(`Stream ready: ${label}`, {
|
||||
id: (stream as any).id,
|
||||
audioTrackCount: audioTracks.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}`));
|
||||
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
||||
|
||||
@@ -8,7 +8,7 @@ export const ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.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 */
|
||||
@@ -51,7 +51,7 @@ export const KBPS_TO_BPS = 1_000;
|
||||
export const LATENCY_PROFILE_BITRATES = {
|
||||
low: 64_000,
|
||||
balanced: 96_000,
|
||||
high: 128_000,
|
||||
high: 128_000
|
||||
} as const;
|
||||
export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('settings')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'settings'"
|
||||
@@ -20,6 +21,7 @@
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('members')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'members'"
|
||||
@@ -31,6 +33,7 @@
|
||||
Members
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('bans')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'bans'"
|
||||
@@ -42,6 +45,7 @@
|
||||
Bans
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('permissions')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'permissions'"
|
||||
@@ -63,9 +67,10 @@
|
||||
|
||||
<!-- Room Name -->
|
||||
<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
|
||||
type="text"
|
||||
id="room-name-input"
|
||||
[(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"
|
||||
/>
|
||||
@@ -73,8 +78,9 @@
|
||||
|
||||
<!-- Room Description -->
|
||||
<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
|
||||
id="room-description-input"
|
||||
[(ngModel)]="roomDescription"
|
||||
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"
|
||||
@@ -88,6 +94,7 @@
|
||||
<p class="text-xs text-muted-foreground">Require approval to join</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePrivate()"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
[class.bg-primary]="isPrivate()"
|
||||
@@ -105,9 +112,10 @@
|
||||
|
||||
<!-- Max Users -->
|
||||
<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
|
||||
type="number"
|
||||
id="max-users-input"
|
||||
[(ngModel)]="maxUsers"
|
||||
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"
|
||||
@@ -116,6 +124,7 @@
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
type="button"
|
||||
(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"
|
||||
>
|
||||
@@ -127,6 +136,7 @@
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
|
||||
<button
|
||||
type="button"
|
||||
(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"
|
||||
>
|
||||
@@ -173,6 +183,7 @@
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
@@ -180,6 +191,7 @@
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="banMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
@@ -225,6 +237,7 @@
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -346,6 +359,7 @@
|
||||
|
||||
<!-- Save Permissions -->
|
||||
<button
|
||||
type="button"
|
||||
(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"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
lucideUnlock
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
@@ -23,9 +24,9 @@ import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
selectOnlineUsers,
|
||||
selectOnlineUsers
|
||||
} 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 { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
@@ -46,10 +47,10 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
}),
|
||||
lucideUnlock
|
||||
})
|
||||
],
|
||||
templateUrl: './admin-panel.component.html',
|
||||
templateUrl: './admin-panel.component.html'
|
||||
})
|
||||
/**
|
||||
* Admin panel for managing room settings, members, bans, and permissions.
|
||||
@@ -87,12 +88,14 @@ export class AdminPanelComponent {
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
const perms = room.permissions || {};
|
||||
|
||||
this.allowVoice = perms.allowVoice !== false;
|
||||
this.allowScreenShare = perms.allowScreenShare !== false;
|
||||
this.allowFileUploads = perms.allowFileUploads !== false;
|
||||
@@ -112,7 +115,9 @@ export class AdminPanelComponent {
|
||||
/** Save the current room name, description, privacy, and max-user settings. */
|
||||
saveSettings(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoom({
|
||||
@@ -121,8 +126,8 @@ export class AdminPanelComponent {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
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). */
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
@@ -143,8 +150,8 @@ export class AdminPanelComponent {
|
||||
adminsManageRooms: this.adminsManageRooms,
|
||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
||||
adminsManageIcon: this.adminsManageIcon,
|
||||
moderatorsManageIcon: this.moderatorsManageIcon,
|
||||
},
|
||||
moderatorsManageIcon: this.moderatorsManageIcon
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -162,7 +169,9 @@ export class AdminPanelComponent {
|
||||
/** Delete the current room after confirmation. */
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
@@ -171,6 +180,7 @@ export class AdminPanelComponent {
|
||||
/** Format a ban expiry timestamp into a human-readable date/time string. */
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
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). */
|
||||
membersFiltered(): User[] {
|
||||
const me = this.currentUser();
|
||||
|
||||
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||
}
|
||||
|
||||
@@ -187,7 +198,7 @@ export class AdminPanelComponent {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
role
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,7 +208,7 @@ export class AdminPanelComponent {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
kickedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,7 +218,7 @@ export class AdminPanelComponent {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'ban',
|
||||
targetUserId: user.id,
|
||||
bannedBy: this.currentUser()?.id,
|
||||
bannedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,28 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="login-username"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="login-password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="serverId"
|
||||
id="login-server"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -38,12 +41,20 @@
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html',
|
||||
templateUrl: './login.component.html'
|
||||
})
|
||||
/**
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
@@ -41,9 +42,12 @@ export class LoginComponent {
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
|
||||
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
@@ -51,15 +55,17 @@ export class LoginComponent {
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,33 +7,37 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="register-username"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="displayName"
|
||||
type="text"
|
||||
id="register-display-name"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="register-password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="serverId"
|
||||
id="register-server"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -46,12 +50,20 @@
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html',
|
||||
templateUrl: './register.component.html'
|
||||
})
|
||||
/**
|
||||
* Registration form allowing new users to create an account on a selected server.
|
||||
@@ -42,9 +43,12 @@ export class RegisterComponent {
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
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({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
@@ -52,15 +56,17 @@ export class RegisterComponent {
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
</div>
|
||||
} @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" />
|
||||
Login
|
||||
</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" />
|
||||
Register
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -11,7 +12,7 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
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.
|
||||
|
||||
@@ -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">
|
||||
<!-- Messages List -->
|
||||
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
|
||||
|
||||
@@ -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 { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
lucideCopy
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
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 { Message } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
|
||||
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
|
||||
@@ -55,16 +55,15 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
||||
lucideDownload,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideCopy,
|
||||
}),
|
||||
lucideCopy
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-messages.component.html',
|
||||
styleUrls: ['./chat-messages.component.scss'],
|
||||
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
|
||||
host: {
|
||||
'(document:keydown)': 'onDocKeydown($event)',
|
||||
'(document:keyup)': 'onDocKeyup($event)',
|
||||
},
|
||||
'(document:keyup)': 'onDocKeyup($event)'
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Real-time chat messages view with infinite scroll, markdown rendering,
|
||||
@@ -100,6 +99,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private allChannelMessages = computed(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
return this.allMessages().filter(message =>
|
||||
message.roomId === roomId && (message.channelId || 'general') === channelId
|
||||
);
|
||||
@@ -109,7 +109,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
if (all.length <= limit) return all;
|
||||
|
||||
if (all.length <= limit)
|
||||
return all;
|
||||
|
||||
return all.slice(all.length - limit);
|
||||
});
|
||||
|
||||
@@ -191,6 +194,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private onMessagesChanged = effect(() => {
|
||||
const currentCount = this.totalChannelMessagesLength();
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!el) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
@@ -204,6 +208,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
|
||||
if (newMessages) {
|
||||
if (distanceFromBottom <= 300) {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
this.lastMessageCount = currentCount;
|
||||
});
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
// First render after connect: scroll to bottom instantly (no animation)
|
||||
// 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.lastMessageCount = 0;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateScrollPadding();
|
||||
}
|
||||
|
||||
@@ -257,18 +267,22 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.boundOnKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
|
||||
|
||||
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this.boundOnKeydown);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
|
||||
if (this.nowTimer) {
|
||||
clearInterval(this.nowTimer);
|
||||
this.nowTimer = null;
|
||||
}
|
||||
|
||||
if (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. */
|
||||
sendMessage(): void {
|
||||
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);
|
||||
|
||||
@@ -285,7 +301,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
MessagesActions.sendMessage({
|
||||
content,
|
||||
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. */
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
||||
try {
|
||||
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. */
|
||||
saveEdit(messageId: string): void {
|
||||
if (!this.editContent.trim()) return;
|
||||
if (!this.editContent.trim())
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.editMessage({
|
||||
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. */
|
||||
scrollToMessage(messageId: string): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
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 currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!message || !currentUserId) return;
|
||||
if (!message || !currentUserId)
|
||||
return;
|
||||
|
||||
const hasReacted = message.reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
|
||||
@@ -418,15 +441,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
message.reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||
|
||||
groups.set(reaction.emoji, {
|
||||
count: existing.count + 1,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([emoji, data]) => ({
|
||||
emoji,
|
||||
...data,
|
||||
...data
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -435,7 +459,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef);
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||
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));
|
||||
@@ -454,6 +477,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private scrollToBottom(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
@@ -470,11 +494,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.stopInitialScrollWatch(); // clean up any prior watcher
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
const snap = () => {
|
||||
if (this.messagesContainer) {
|
||||
const e = this.messagesContainer.nativeElement;
|
||||
|
||||
this.isAutoScrolling = true;
|
||||
e.scrollTop = e.scrollHeight;
|
||||
// Clear flag after browser fires the synchronous scroll event
|
||||
@@ -490,7 +517,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src'], // img src swaps
|
||||
attributeFilter: ['src'] // img src swaps
|
||||
});
|
||||
|
||||
// 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 = null;
|
||||
}
|
||||
|
||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||
this.boundOnImageLoad = null;
|
||||
}
|
||||
|
||||
if (this.initialScrollTimer) {
|
||||
clearTimeout(this.initialScrollTimer);
|
||||
this.initialScrollTimer = null;
|
||||
@@ -519,12 +548,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private scrollToBottomSmooth(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
} catch {
|
||||
// Fallback if smooth not supported
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
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. */
|
||||
onScroll(): void {
|
||||
if (!this.messagesContainer) return;
|
||||
if (!this.messagesContainer)
|
||||
return;
|
||||
|
||||
// Ignore scroll events caused by programmatic snap-to-bottom
|
||||
if (this.isAutoScrolling) return;
|
||||
if (this.isAutoScrolling)
|
||||
return;
|
||||
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
|
||||
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
||||
|
||||
if (this.shouldScrollToBottom) {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
// Any user-initiated scroll during the initial load period
|
||||
// immediately hands control back to the user
|
||||
if (this.initialScrollObserver) {
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
|
||||
// Infinite scroll upwards — load older messages when near the top
|
||||
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
@@ -561,7 +599,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Load older messages by expanding the display window, preserving scroll position */
|
||||
loadMore(): void {
|
||||
if (this.loadingMore() || !this.hasMoreMessages()) return;
|
||||
if (this.loadingMore() || !this.hasMoreMessages())
|
||||
return;
|
||||
|
||||
this.loadingMore.set(true);
|
||||
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
@@ -574,8 +614,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const newScrollHeight = el.scrollHeight;
|
||||
|
||||
el.scrollTop += newScrollHeight - prevScrollHeight;
|
||||
}
|
||||
|
||||
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). */
|
||||
onEnter(evt: Event): void {
|
||||
const keyEvent = evt as KeyboardEvent;
|
||||
|
||||
if (keyEvent.shiftKey) {
|
||||
// allow newline
|
||||
return;
|
||||
}
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (el) {
|
||||
el.selectionStart = start;
|
||||
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.). */
|
||||
applyInline(token: string): void {
|
||||
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
|
||||
|
||||
this.messageContent = result.text;
|
||||
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). */
|
||||
applyPrefix(prefix: string): void {
|
||||
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
|
||||
|
||||
this.messageContent = result.text;
|
||||
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. */
|
||||
applyHeading(level: number): void {
|
||||
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
|
||||
|
||||
this.messageContent = result.text;
|
||||
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. */
|
||||
applyOrderedList(): void {
|
||||
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
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. */
|
||||
applyCodeBlock(): void {
|
||||
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
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. */
|
||||
applyLink(): void {
|
||||
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
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. */
|
||||
applyImage(): void {
|
||||
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
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. */
|
||||
applyHorizontalRule(): void {
|
||||
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
@@ -687,12 +741,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
evt.preventDefault();
|
||||
const files: File[] = [];
|
||||
const items = evt.dataTransfer?.items;
|
||||
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
|
||||
if (file)
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
} else if (evt.dataTransfer?.files?.length) {
|
||||
@@ -700,6 +758,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
files.push(evt.dataTransfer.files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file) => this.pendingFiles.push(file));
|
||||
// Keep toolbar visible so user sees options
|
||||
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). */
|
||||
formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
let size = bytes;
|
||||
let i = 0;
|
||||
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
|
||||
return `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Format a transfer speed in bytes/second to a human-readable 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'];
|
||||
|
||||
let speed = bps;
|
||||
let i = 0;
|
||||
|
||||
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
|
||||
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Remove a pending file from the upload queue. */
|
||||
removePendingFile(file: File): void {
|
||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||
|
||||
if (idx >= 0) {
|
||||
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. */
|
||||
downloadAttachment(att: Attachment): void {
|
||||
if (!att.available || !att.objectUrl) return;
|
||||
if (!att.available || !att.objectUrl)
|
||||
return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = att.objectUrl;
|
||||
a.download = att.filename;
|
||||
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. */
|
||||
isUploader(att: Attachment): boolean {
|
||||
const myUserId = this.currentUser()?.id;
|
||||
|
||||
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. */
|
||||
async copyImageToClipboard(att: Attachment): Promise<void> {
|
||||
this.closeImageContextMenu();
|
||||
if (!att.objectUrl) return;
|
||||
|
||||
if (!att.objectUrl)
|
||||
return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(att.objectUrl);
|
||||
const blob = await resp.blob();
|
||||
// Convert to PNG for clipboard compatibility
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob }),
|
||||
new ClipboardItem({ 'image/png': pngBlob })
|
||||
]);
|
||||
} catch (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
@@ -814,22 +890,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) { reject(new Error('Canvas not supported')); return; }
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob((pngBlob) => {
|
||||
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');
|
||||
};
|
||||
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@@ -841,14 +927,20 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
private attachFilesToLastOwnMessage(content: string): void {
|
||||
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);
|
||||
|
||||
if (!msg) {
|
||||
// Retry shortly until message appears
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaderPeerId = this.currentUser()?.id || undefined;
|
||||
|
||||
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
|
||||
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. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 520) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
@@ -868,7 +963,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
requestAnimationFrame(() => {
|
||||
const bar = this.bottomBar?.nativeElement;
|
||||
const scroll = this.messagesContainer?.nativeElement;
|
||||
if (!bar || !scroll) return;
|
||||
|
||||
if (!bar || !scroll)
|
||||
return;
|
||||
|
||||
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. */
|
||||
onToolbarMouseLeave(): void {
|
||||
this.toolbarHovering = false;
|
||||
|
||||
if (document.activeElement !== this.messageInputRef?.nativeElement) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
@@ -902,12 +1001,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Handle Ctrl key down for enabling manual resize. */
|
||||
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. */
|
||||
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. */
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ChatMarkdownService {
|
||||
const after = content.slice(end);
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ export class ChatMarkdownService {
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ export class ChatMarkdownService {
|
||||
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -58,6 +61,7 @@ export class ChatMarkdownService {
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ export class ChatMarkdownService {
|
||||
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
@@ -80,6 +85,7 @@ export class ChatMarkdownService {
|
||||
const link = `[${selected}](https://)`;
|
||||
const text = `${before}${link}${after}`;
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
@@ -92,6 +98,7 @@ export class ChatMarkdownService {
|
||||
const img = ``;
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
@@ -99,26 +106,33 @@ export class ChatMarkdownService {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const after = content.slice(end);
|
||||
const hr = `\n\n---\n\n`;
|
||||
const hr = '\n\n---\n\n';
|
||||
const text = `${before}${hr}${after}`;
|
||||
const cursor = before.length + hr.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
appendImageMarkdown(content: string): string {
|
||||
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
|
||||
const urls = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const text = content;
|
||||
|
||||
while ((match = imageUrlRegex.exec(text)) !== null) {
|
||||
urls.add(match[1]);
|
||||
}
|
||||
|
||||
if (urls.size === 0) return content;
|
||||
if (urls.size === 0)
|
||||
return content;
|
||||
|
||||
let append = '';
|
||||
|
||||
for (const url of urls) {
|
||||
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
|
||||
|
||||
if (!alreadyEmbedded) {
|
||||
append += `\n`;
|
||||
}
|
||||
|
||||
@@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -13,8 +14,8 @@ const MAX_SHOWN = 4;
|
||||
templateUrl: './typing-indicator.component.html',
|
||||
host: {
|
||||
'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 {
|
||||
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||
@@ -25,30 +26,31 @@ export class TypingIndicatorComponent {
|
||||
constructor() {
|
||||
const webrtc = inject(WebRTCService);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
|
||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
|
||||
tap((msg: any) => {
|
||||
const now = Date.now();
|
||||
|
||||
this.typingMap.set(String(msg.oderId), {
|
||||
name: String(msg.displayName),
|
||||
expiresAt: now + TYPING_TTL,
|
||||
expiresAt: now + TYPING_TTL
|
||||
});
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const purge$ = interval(PURGE_INTERVAL).pipe(
|
||||
map(() => Date.now()),
|
||||
filter((now) => {
|
||||
let changed = false;
|
||||
|
||||
for (const [key, entry] of this.typingMap) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.typingMap.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
merge(typing$, purge$)
|
||||
@@ -61,6 +63,7 @@ export class TypingIndicatorComponent {
|
||||
const names = Array.from(this.typingMap.values())
|
||||
.filter((e) => e.expiresAt > now)
|
||||
.map((e) => e.name);
|
||||
|
||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(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 -->
|
||||
<div class="relative">
|
||||
@@ -65,9 +71,13 @@
|
||||
<div
|
||||
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()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="muteUser(user)"
|
||||
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
|
||||
type="button"
|
||||
(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"
|
||||
>
|
||||
@@ -88,6 +99,7 @@
|
||||
<span>Kick</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(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"
|
||||
>
|
||||
@@ -121,19 +133,21 @@
|
||||
</p>
|
||||
|
||||
<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
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
[(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"
|
||||
>
|
||||
<option value="3600000">1 hour</option>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,14 +14,14 @@ import {
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
@@ -40,10 +41,10 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
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.
|
||||
@@ -79,6 +80,7 @@ export class UserListComponent {
|
||||
} else {
|
||||
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
||||
}
|
||||
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
@@ -106,7 +108,9 @@ export class UserListComponent {
|
||||
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
|
||||
confirmBan(): void {
|
||||
const user = this.userToBan();
|
||||
if (!user) return;
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
const duration = parseInt(this.banDuration, 10);
|
||||
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
||||
@@ -115,7 +119,7 @@ export class UserListComponent {
|
||||
UsersActions.banUser({
|
||||
userId: user.id,
|
||||
reason: this.banReason || undefined,
|
||||
expiresAt,
|
||||
expiresAt
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideChevronLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
|
||||
@@ -19,13 +20,11 @@ import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.co
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
selectTextChannels,
|
||||
selectTextChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
|
||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
standalone: true,
|
||||
@@ -34,7 +33,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
ScreenShareViewerComponent,
|
||||
RoomsSidePanelComponent,
|
||||
RoomsSidePanelComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -43,10 +42,10 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
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.
|
||||
@@ -67,12 +66,14 @@ export class ChatRoomComponent {
|
||||
get activeChannelName(): string {
|
||||
const id = this.activeChannelId();
|
||||
const activeChannel = this.textChannels().find((channel) => channel.id === id);
|
||||
|
||||
return activeChannel ? activeChannel.name : id;
|
||||
}
|
||||
|
||||
/** Open the settings modal to the Server admin page for the current room. */
|
||||
toggleAdminPanel() {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room) {
|
||||
this.settingsModal.open('server', room.id);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<!-- Minimalistic header with tabs -->
|
||||
<div class="border-b border-border">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -11,18 +12,18 @@ import {
|
||||
lucideMonitor,
|
||||
lucideHash,
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
lucidePlus
|
||||
} from '@ng-icons/lucide';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
selectTextChannels,
|
||||
selectVoiceChannels,
|
||||
selectVoiceChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
@@ -47,7 +48,7 @@ type TabView = 'channels' | 'users';
|
||||
VoiceControlsComponent,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -58,10 +59,10 @@ type TabView = 'channels' | 'users';
|
||||
lucideMonitor,
|
||||
lucideHash,
|
||||
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.
|
||||
@@ -108,8 +109,9 @@ export class RoomsSidePanelComponent {
|
||||
const current = this.currentUser();
|
||||
const currentId = current?.id;
|
||||
const currentOderId = current?.oderId;
|
||||
|
||||
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 {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
if (!room || !user) return false;
|
||||
|
||||
if (!room || !user)
|
||||
return false;
|
||||
|
||||
// Owner always can
|
||||
if (room.hostId === user.id) return true;
|
||||
if (room.hostId === user.id)
|
||||
return true;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Select a text channel (no-op if currently renaming). */
|
||||
// ---- Text channel selection ----
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -151,7 +165,9 @@ export class RoomsSidePanelComponent {
|
||||
/** Begin inline renaming of the context-menu channel. */
|
||||
startRename() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
this.closeChannelMenu();
|
||||
|
||||
if (ch) {
|
||||
this.renamingChannelId.set(ch.id);
|
||||
}
|
||||
@@ -162,9 +178,11 @@ export class RoomsSidePanelComponent {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const name = input.value.trim();
|
||||
const channelId = this.renamingChannelId();
|
||||
|
||||
if (channelId && name) {
|
||||
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
|
||||
}
|
||||
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
@@ -176,7 +194,9 @@ export class RoomsSidePanelComponent {
|
||||
/** Delete the context-menu channel. */
|
||||
deleteChannel() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
this.closeChannelMenu();
|
||||
|
||||
if (ch) {
|
||||
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
|
||||
}
|
||||
@@ -186,6 +206,7 @@ export class RoomsSidePanelComponent {
|
||||
resyncMessages() {
|
||||
this.closeChannelMenu();
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
@@ -195,9 +216,11 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
// Request inventory from all connected peers
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (peers.length === 0) {
|
||||
// No connected peers — sync will time out
|
||||
}
|
||||
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
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. */
|
||||
confirmCreateChannel() {
|
||||
const name = this.newChannelName.trim();
|
||||
if (!name) return;
|
||||
|
||||
if (!name)
|
||||
return;
|
||||
|
||||
const type = this.createChannelType();
|
||||
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
|
||||
const channel: Channel = {
|
||||
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
|
||||
name,
|
||||
type,
|
||||
position: existing.length,
|
||||
position: existing.length
|
||||
};
|
||||
|
||||
this.store.dispatch(RoomsActions.addChannel({ channel }));
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
@@ -240,7 +267,10 @@ export class RoomsSidePanelComponent {
|
||||
// ---- User context menu (kick/role) ----
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
if (!this.isAdmin()) return;
|
||||
|
||||
if (!this.isAdmin())
|
||||
return;
|
||||
|
||||
this.contextMenuUser.set(user);
|
||||
this.userMenuX.set(evt.clientX);
|
||||
this.userMenuY.set(evt.clientY);
|
||||
@@ -255,14 +285,16 @@ export class RoomsSidePanelComponent {
|
||||
/** Change a user's role and broadcast the update to connected peers. */
|
||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||
const user = this.contextMenuUser();
|
||||
|
||||
this.closeUserMenu();
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
// Broadcast role change to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
role
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -270,14 +302,16 @@ export class RoomsSidePanelComponent {
|
||||
/** Kick a user and broadcast the action to peers. */
|
||||
kickUserAction() {
|
||||
const user = this.contextMenuUser();
|
||||
|
||||
this.closeUserMenu();
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
// Broadcast kick to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
kickedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -287,6 +321,7 @@ export class RoomsSidePanelComponent {
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
// Voice is disabled by room permissions
|
||||
return;
|
||||
@@ -309,9 +344,9 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
}),
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -325,7 +360,6 @@ export class RoomsSidePanelComponent {
|
||||
current?.voiceState?.isConnected &&
|
||||
current.voiceState.serverId === room?.id &&
|
||||
current.voiceState.roomId !== roomId;
|
||||
|
||||
// Enable microphone and broadcast voice-state
|
||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||
|
||||
@@ -340,11 +374,12 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room.id,
|
||||
},
|
||||
}),
|
||||
serverId: room.id
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
|
||||
this.webrtc.broadcastMessage({
|
||||
@@ -356,8 +391,8 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room?.id,
|
||||
},
|
||||
serverId: room?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Update voice session for floating controls
|
||||
@@ -365,6 +400,7 @@ export class RoomsSidePanelComponent {
|
||||
// Find label from channel list
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
@@ -372,7 +408,7 @@ export class RoomsSidePanelComponent {
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
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. */
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
|
||||
// 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
|
||||
this.webrtc.stopVoiceHeartbeat();
|
||||
@@ -403,9 +441,9 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
}),
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -419,8 +457,8 @@ export class RoomsSidePanelComponent {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
serverId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
// End voice session
|
||||
@@ -431,50 +469,59 @@ export class RoomsSidePanelComponent {
|
||||
voiceOccupancy(roomId: string): number {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return users.filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id,
|
||||
user.voiceState?.serverId === room?.id
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||
viewShare(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's stream. */
|
||||
viewStream(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Check whether a user is currently sharing their screen. */
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
|
||||
if (me?.id === userId) {
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
|
||||
const user = this.onlineUsers().find(
|
||||
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId,
|
||||
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
|
||||
);
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream = this.webrtc.getRemoteStream(userId);
|
||||
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
/** Return all users currently connected to a specific voice channel. */
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return this.onlineUsers().filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
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 {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!(
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
@@ -492,6 +540,7 @@ export class RoomsSidePanelComponent {
|
||||
/** Check whether voice is enabled by the current room's permissions. */
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
}
|
||||
|
||||
@@ -501,6 +550,7 @@ export class RoomsSidePanelComponent {
|
||||
*/
|
||||
getPeerLatency(user: User): number | null {
|
||||
const latencies = this.webrtc.peerLatencies();
|
||||
|
||||
// Try oderId first (primary peer key), then fall back to user id
|
||||
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
|
||||
}
|
||||
@@ -515,10 +565,19 @@ export class RoomsSidePanelComponent {
|
||||
*/
|
||||
getPingColorClass(user: User): string {
|
||||
const ms = this.getPeerLatency(user);
|
||||
if (ms === null) return 'bg-gray-500';
|
||||
if (ms < 100) return 'bg-green-500';
|
||||
if (ms < 200) return 'bg-yellow-500';
|
||||
if (ms < 350) return 'bg-orange-500';
|
||||
|
||||
if (ms === null)
|
||||
return 'bg-gray-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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
{{ room.name }}
|
||||
@@ -35,6 +36,7 @@
|
||||
</div>
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
type="button"
|
||||
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
@@ -47,6 +49,7 @@
|
||||
<div class="p-4 border-b border-border">
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
||||
@@ -71,6 +74,7 @@
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
@@ -119,37 +123,56 @@
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeCreateDialog()">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()">
|
||||
<div
|
||||
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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
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"
|
||||
></textarea>
|
||||
</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
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -166,11 +189,12 @@
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<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
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Enter password"
|
||||
id="create-server-password"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -180,6 +204,7 @@
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeCreateDialog()"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
@@ -187,6 +212,7 @@
|
||||
<button
|
||||
(click)="createServer()"
|
||||
[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"
|
||||
>
|
||||
Create
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
lucideSettings
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms,
|
||||
selectSavedRooms
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import { Room } from '../../core/models';
|
||||
import { ServerInfo } from '../../core/models';
|
||||
@@ -36,10 +37,10 @@ import { SettingsModalService } from '../../core/services/settings-modal.service
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
}),
|
||||
lucideSettings
|
||||
})
|
||||
],
|
||||
templateUrl: './server-search.component.html',
|
||||
templateUrl: './server-search.component.html'
|
||||
})
|
||||
/**
|
||||
* 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. */
|
||||
joinServer(server: ServerInfo): void {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: server.id,
|
||||
serverInfo: {
|
||||
name: server.name,
|
||||
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. */
|
||||
createServer(): void {
|
||||
if (!this.newServerName()) return;
|
||||
if (!this.newServerName())
|
||||
return;
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
@@ -127,8 +133,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
description: this.newServerDescription() || undefined,
|
||||
topic: this.newServerTopic() || undefined,
|
||||
isPrivate: this.newServerPrivate(),
|
||||
password: this.newServerPrivate() ? this.newServerPassword() : undefined,
|
||||
}),
|
||||
password: this.newServerPrivate() ? this.newServerPassword() : undefined
|
||||
})
|
||||
);
|
||||
|
||||
this.closeCreateDialog();
|
||||
@@ -149,7 +155,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
isPrivate: !!room.password,
|
||||
createdAt: room.createdAt,
|
||||
createdAt: room.createdAt
|
||||
} as any);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -17,7 +18,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
|
||||
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.
|
||||
@@ -40,8 +41,11 @@ export class ServersRailComponent {
|
||||
|
||||
/** Return the first character of a server name as its icon initial. */
|
||||
initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
if (!name)
|
||||
return '?';
|
||||
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
|
||||
return ch || '?';
|
||||
}
|
||||
|
||||
@@ -52,9 +56,11 @@ export class ServersRailComponent {
|
||||
// Navigate to server list (has create button)
|
||||
// Update voice session state if connected to voice
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
}
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
@@ -62,6 +68,7 @@ export class ServersRailComponent {
|
||||
joinSavedRoom(room: Room): void {
|
||||
// Require auth: if no current user, go to login
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
@@ -69,6 +76,7 @@ export class ServersRailComponent {
|
||||
|
||||
// Check if we're navigating to a different server while in voice
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId && voiceServerId !== room.id) {
|
||||
// User is switching to a different server while connected to voice
|
||||
// Update voice session to show floating controls (voice stays connected)
|
||||
@@ -89,8 +97,8 @@ export class ServersRailComponent {
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
},
|
||||
hostName: room.hostId || 'Unknown'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +123,7 @@ export class ServersRailComponent {
|
||||
isCurrentContextRoom(): boolean {
|
||||
const ctx = this.contextRoom();
|
||||
const cur = this.currentRoom();
|
||||
|
||||
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. */
|
||||
confirmForget(): void {
|
||||
const ctx = this.contextRoom();
|
||||
if (!ctx) return;
|
||||
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
if (this.currentRoom()?.id === ctx.id) {
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id }));
|
||||
this.showConfirm.set(false);
|
||||
this.contextRoom.set(null);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -14,10 +15,10 @@ import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideX,
|
||||
}),
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './bans-settings.component.html',
|
||||
templateUrl: './bans-settings.component.html'
|
||||
})
|
||||
export class BansSettingsComponent {
|
||||
private store = inject(Store);
|
||||
@@ -35,6 +36,7 @@ export class BansSettingsComponent {
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return (
|
||||
date.toLocaleDateString() +
|
||||
' ' +
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -18,10 +19,10 @@ import { UserAvatarComponent } from '../../../../shared';
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideUserX,
|
||||
lucideBan,
|
||||
}),
|
||||
lucideBan
|
||||
})
|
||||
],
|
||||
templateUrl: './members-settings.component.html',
|
||||
templateUrl: './members-settings.component.html'
|
||||
})
|
||||
export class MembersSettingsComponent {
|
||||
private store = inject(Store);
|
||||
@@ -37,6 +38,7 @@ export class MembersSettingsComponent {
|
||||
|
||||
membersFiltered(): User[] {
|
||||
const me = this.currentUser();
|
||||
|
||||
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||
}
|
||||
|
||||
@@ -45,7 +47,7 @@ export class MembersSettingsComponent {
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
role,
|
||||
role
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ export class MembersSettingsComponent {
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
kickedBy: this.currentUser()?.id,
|
||||
kickedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ export class MembersSettingsComponent {
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'ban',
|
||||
targetUserId: user.id,
|
||||
bannedBy: this.currentUser()?.id,
|
||||
bannedBy: this.currentUser()?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideCheck
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
||||
@@ -25,10 +26,10 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
}),
|
||||
lucideCheck
|
||||
})
|
||||
],
|
||||
templateUrl: './network-settings.component.html',
|
||||
templateUrl: './network-settings.component.html'
|
||||
})
|
||||
export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
@@ -47,25 +48,30 @@ export class NetworkSettingsComponent {
|
||||
|
||||
addServer(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
try {
|
||||
new URL(this.newServerUrl);
|
||||
} catch {
|
||||
this.addError.set('Please enter a valid URL');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverDirectory.addServer({
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, ''),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, '')
|
||||
});
|
||||
this.newServerName = '';
|
||||
this.newServerUrl = '';
|
||||
const servers = this.servers();
|
||||
const newServer = servers[servers.length - 1];
|
||||
if (newServer) this.serverDirectory.testServer(newServer.id);
|
||||
|
||||
if (newServer)
|
||||
this.serverDirectory.testServer(newServer.id);
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
@@ -84,8 +90,10 @@ export class NetworkSettingsComponent {
|
||||
|
||||
loadConnectionSettings(): void {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
this.autoReconnect = parsed.autoReconnect ?? true;
|
||||
this.searchAllServers = parsed.searchAllServers ?? true;
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
@@ -97,8 +105,8 @@ export class NetworkSettingsComponent {
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers,
|
||||
}),
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -14,10 +15,10 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
}),
|
||||
lucideCheck
|
||||
})
|
||||
],
|
||||
templateUrl: './permissions-settings.component.html',
|
||||
templateUrl: './permissions-settings.component.html'
|
||||
})
|
||||
export class PermissionsSettingsComponent {
|
||||
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. */
|
||||
loadPermissions(room: Room): void {
|
||||
const perms = room.permissions || {};
|
||||
|
||||
this.allowVoice = perms.allowVoice !== false;
|
||||
this.allowScreenShare = perms.allowScreenShare !== false;
|
||||
this.allowFileUploads = perms.allowFileUploads !== false;
|
||||
@@ -54,7 +56,10 @@ export class PermissionsSettingsComponent {
|
||||
|
||||
savePermissions(): void {
|
||||
const room = this.server();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
roomId: room.id,
|
||||
@@ -66,16 +71,19 @@ export class PermissionsSettingsComponent {
|
||||
adminsManageRooms: this.adminsManageRooms,
|
||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
||||
adminsManageIcon: this.adminsManageIcon,
|
||||
moderatorsManageIcon: this.moderatorsManageIcon,
|
||||
},
|
||||
}),
|
||||
moderatorsManageIcon: this.moderatorsManageIcon
|
||||
}
|
||||
})
|
||||
);
|
||||
this.showSaveSuccess('permissions');
|
||||
}
|
||||
|
||||
private showSaveSuccess(key: string): void {
|
||||
this.saveSuccess.set(key);
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||
|
||||
if (this.saveTimeout)
|
||||
clearTimeout(this.saveTimeout);
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,24 @@
|
||||
}
|
||||
<div class="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
[(ngModel)]="roomName"
|
||||
[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.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="roomDescription"
|
||||
[readOnly]="!isAdmin()"
|
||||
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.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
@@ -39,6 +41,7 @@
|
||||
</div>
|
||||
<button
|
||||
(click)="togglePrivate()"
|
||||
type="button"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
[class.bg-primary]="isPrivate()"
|
||||
[class.text-primary-foreground]="isPrivate()"
|
||||
@@ -62,14 +65,15 @@
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Max Users (0 = unlimited)</label
|
||||
>
|
||||
<label for="room-max-users" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Max Users (0 = unlimited)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="maxUsers"
|
||||
[readOnly]="!isAdmin()"
|
||||
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.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
@@ -81,6 +85,7 @@
|
||||
@if (isAdmin()) {
|
||||
<button
|
||||
(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.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>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -19,10 +20,10 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
lucideCheck,
|
||||
lucideTrash2,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
}),
|
||||
lucideUnlock
|
||||
})
|
||||
],
|
||||
templateUrl: './server-settings.component.html',
|
||||
templateUrl: './server-settings.component.html'
|
||||
})
|
||||
export class ServerSettingsComponent {
|
||||
private store = inject(Store);
|
||||
@@ -45,22 +46,27 @@ export class ServerSettingsComponent {
|
||||
/** Reload form fields whenever the server input changes. */
|
||||
serverData = computed(() => {
|
||||
const room = this.server();
|
||||
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
}
|
||||
|
||||
return room;
|
||||
});
|
||||
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((v) => !v);
|
||||
this.isPrivate.update((currentValue) => !currentValue);
|
||||
}
|
||||
|
||||
saveServerSettings(): void {
|
||||
const room = this.server();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
@@ -68,9 +74,9 @@ export class ServerSettingsComponent {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers,
|
||||
},
|
||||
}),
|
||||
maxUsers: this.maxUsers
|
||||
}
|
||||
})
|
||||
);
|
||||
this.showSaveSuccess('server');
|
||||
}
|
||||
@@ -81,7 +87,10 @@ export class ServerSettingsComponent {
|
||||
|
||||
deleteRoom(): void {
|
||||
const room = this.server();
|
||||
if (!room) return;
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
this.modal.navigate('network');
|
||||
@@ -89,7 +98,10 @@ export class ServerSettingsComponent {
|
||||
|
||||
private showSaveSuccess(key: string): void {
|
||||
this.saveSuccess.set(key);
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||
|
||||
if (this.saveTimeout)
|
||||
clearTimeout(this.saveTimeout);
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
[class.opacity-100]="animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="onBackdropClick()"
|
||||
(keydown.enter)="onBackdropClick()"
|
||||
(keydown.space)="onBackdropClick()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close settings"
|
||||
></div>
|
||||
|
||||
<!-- Modal -->
|
||||
@@ -16,6 +21,11 @@
|
||||
[class.scale-95]="!animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
(keydown.space)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Side Navigation -->
|
||||
<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) {
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
type="button"
|
||||
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.text-primary]="activePage() === page.id"
|
||||
@@ -72,6 +83,7 @@
|
||||
@for (page of serverPages; track page.id) {
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
type="button"
|
||||
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.text-primary]="activePage() === page.id"
|
||||
@@ -119,6 +131,7 @@
|
||||
</h3>
|
||||
<button
|
||||
(click)="close()"
|
||||
type="button"
|
||||
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" />
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
effect,
|
||||
HostListener,
|
||||
viewChild,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -20,13 +19,13 @@ import {
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
lucideShield,
|
||||
lucideShield
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectCurrentUser,
|
||||
selectCurrentUser
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { Room } from '../../../core/models';
|
||||
|
||||
@@ -49,7 +48,7 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
||||
ServerSettingsComponent,
|
||||
MembersSettingsComponent,
|
||||
BansSettingsComponent,
|
||||
PermissionsSettingsComponent,
|
||||
PermissionsSettingsComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -59,12 +58,12 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
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);
|
||||
private store = inject(Store);
|
||||
|
||||
@@ -82,21 +81,24 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
// --- Side-nav items ---
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ 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 }[] = [
|
||||
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
||||
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
||||
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
||||
];
|
||||
|
||||
// ===== SERVER SELECTOR =====
|
||||
selectedServerId = signal<string | null>(null);
|
||||
selectedServer = computed<Room | null>(() => {
|
||||
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. */
|
||||
@@ -108,7 +110,10 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
isSelectedServerAdmin = computed(() => {
|
||||
const server = this.selectedServer();
|
||||
const user = this.currentUser();
|
||||
if (!server || !user) return false;
|
||||
|
||||
if (!server || !user)
|
||||
return false;
|
||||
|
||||
return server.hostId === user.id || server.hostId === user.oderId;
|
||||
});
|
||||
|
||||
@@ -120,11 +125,17 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
effect(() => {
|
||||
if (this.isOpen()) {
|
||||
const targetId = this.modal.targetServerId();
|
||||
|
||||
if (targetId) {
|
||||
this.selectedServerId.set(targetId);
|
||||
} else if (this.currentRoom()) {
|
||||
this.selectedServerId.set(this.currentRoom()!.id);
|
||||
} else {
|
||||
const currentRoom = this.currentRoom();
|
||||
|
||||
if (currentRoom) {
|
||||
this.selectedServerId.set(currentRoom.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.animating.set(true);
|
||||
}
|
||||
});
|
||||
@@ -132,19 +143,16 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
// When selected server changes, reload permissions data
|
||||
effect(() => {
|
||||
const server = this.selectedServer();
|
||||
|
||||
if (server) {
|
||||
const permsComp = this.permissionsComponent();
|
||||
|
||||
if (permsComp) {
|
||||
permsComp.loadPermissions(server);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.isOpen()) {
|
||||
@@ -168,6 +176,7 @@ export class SettingsModalComponent implements OnInit, OnDestroy {
|
||||
|
||||
onServerSelect(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedServerId.set(select.value || null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
(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"
|
||||
>
|
||||
@for (device of inputDevices(); track device.deviceId) {
|
||||
@@ -23,9 +24,10 @@
|
||||
</select>
|
||||
</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
|
||||
(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"
|
||||
>
|
||||
@for (device of outputDevices(); track device.deviceId) {
|
||||
@@ -49,7 +51,7 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<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() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -58,11 +60,12 @@
|
||||
(input)="onInputVolumeChange($event)"
|
||||
min="0"
|
||||
max="100"
|
||||
id="input-volume-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</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() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -71,11 +74,12 @@
|
||||
(input)="onOutputVolumeChange($event)"
|
||||
min="0"
|
||||
max="100"
|
||||
id="output-volume-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</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' }}%
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -86,10 +90,12 @@
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
id="notification-volume-slider"
|
||||
class="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<button
|
||||
(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"
|
||||
title="Preview notification sound"
|
||||
>
|
||||
@@ -111,9 +117,10 @@
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
(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"
|
||||
>
|
||||
<option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option>
|
||||
@@ -122,7 +129,7 @@
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@@ -132,6 +139,7 @@
|
||||
min="32"
|
||||
max="256"
|
||||
step="8"
|
||||
id="audio-bitrate-slider"
|
||||
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -145,6 +153,8 @@
|
||||
type="checkbox"
|
||||
[checked]="noiseReduction()"
|
||||
(change)="onNoiseReductionChange()"
|
||||
id="noise-reduction-toggle"
|
||||
aria-label="Toggle noise reduction"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
@@ -162,6 +172,8 @@
|
||||
type="checkbox"
|
||||
[checked]="includeSystemAudio()"
|
||||
(change)="onIncludeSystemAudioChange($event)"
|
||||
id="system-audio-toggle"
|
||||
aria-label="Toggle system audio in screen share"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
@@ -190,6 +202,8 @@
|
||||
type="checkbox"
|
||||
[checked]="voiceLeveling.enabled()"
|
||||
(change)="onVoiceLevelingToggle()"
|
||||
id="voice-leveling-toggle"
|
||||
aria-label="Toggle voice leveling"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
@@ -203,7 +217,7 @@
|
||||
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
|
||||
<!-- Target Loudness -->
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -213,6 +227,7 @@
|
||||
min="-30"
|
||||
max="-12"
|
||||
step="1"
|
||||
id="target-loudness-slider"
|
||||
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">
|
||||
@@ -223,9 +238,10 @@
|
||||
|
||||
<!-- AGC Strength -->
|
||||
<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
|
||||
(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"
|
||||
>
|
||||
<option value="low" [selected]="voiceLeveling.strength() === 'low'">
|
||||
@@ -242,7 +258,7 @@
|
||||
|
||||
<!-- Max Gain Boost -->
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -252,6 +268,7 @@
|
||||
min="3"
|
||||
max="20"
|
||||
step="1"
|
||||
id="max-gain-slider"
|
||||
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">
|
||||
@@ -262,11 +279,12 @@
|
||||
|
||||
<!-- Response Speed -->
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
(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"
|
||||
>
|
||||
<option value="slow" [selected]="voiceLeveling.speed() === 'slow'">
|
||||
@@ -290,6 +308,8 @@
|
||||
type="checkbox"
|
||||
[checked]="voiceLeveling.noiseGate()"
|
||||
(change)="onNoiseGateToggle()"
|
||||
id="noise-gate-toggle"
|
||||
aria-label="Toggle noise floor gate"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
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 {
|
||||
NotificationAudioService,
|
||||
AppSound,
|
||||
AppSound
|
||||
} from '../../../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
||||
|
||||
@@ -26,10 +27,10 @@ interface AudioDevice {
|
||||
lucideMic,
|
||||
lucideHeadphones,
|
||||
lucideAudioLines,
|
||||
lucideActivity,
|
||||
}),
|
||||
lucideActivity
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-settings.component.html',
|
||||
templateUrl: './voice-settings.component.html'
|
||||
})
|
||||
export class VoiceSettingsComponent {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
@@ -54,17 +55,20 @@ export class VoiceSettingsComponent {
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return;
|
||||
if (!navigator.mediaDevices?.enumerateDevices)
|
||||
return;
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audioinput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audiooutput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
@@ -72,18 +76,37 @@ export class VoiceSettingsComponent {
|
||||
loadVoiceSettings(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
if (!raw) return;
|
||||
const s = JSON.parse(raw);
|
||||
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
|
||||
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
|
||||
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
|
||||
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
|
||||
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
|
||||
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
|
||||
if (typeof s.includeSystemAudio === 'boolean')
|
||||
this.includeSystemAudio.set(s.includeSystemAudio);
|
||||
if (typeof s.noiseReduction === 'boolean') this.noiseReduction.set(s.noiseReduction);
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const settings = JSON.parse(raw);
|
||||
|
||||
if (settings.inputDevice)
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
|
||||
if (settings.outputDevice)
|
||||
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 {}
|
||||
|
||||
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) {
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
}
|
||||
@@ -101,20 +124,22 @@ export class VoiceSettingsComponent {
|
||||
audioBitrate: this.audioBitrate(),
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
noiseReduction: this.noiseReduction(),
|
||||
}),
|
||||
noiseReduction: this.noiseReduction()
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
@@ -122,12 +147,14 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.saveVoiceSettings();
|
||||
@@ -136,6 +163,7 @@ export class VoiceSettingsComponent {
|
||||
onLatencyProfileChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const profile = select.value as 'low' | 'balanced' | 'high';
|
||||
|
||||
this.latencyProfile.set(profile);
|
||||
this.webrtcService.setLatencyProfile(profile);
|
||||
this.saveVoiceSettings();
|
||||
@@ -143,6 +171,7 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.audioBitrate.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setAudioBitrate(this.audioBitrate());
|
||||
this.saveVoiceSettings();
|
||||
@@ -150,12 +179,13 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
|
||||
async onNoiseReductionChange(): Promise<void> {
|
||||
this.noiseReduction.update((v) => !v);
|
||||
this.noiseReduction.update((currentValue) => !currentValue);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveVoiceSettings();
|
||||
}
|
||||
@@ -168,21 +198,25 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onTargetDbfsChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.voiceLeveling.setTargetDbfs(parseInt(input.value, 10));
|
||||
}
|
||||
|
||||
onStrengthChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.voiceLeveling.setStrength(select.value as 'low' | 'medium' | 'high');
|
||||
}
|
||||
|
||||
onMaxGainDbChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.voiceLeveling.setMaxGainDb(parseInt(input.value, 10));
|
||||
}
|
||||
|
||||
onSpeedChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.voiceLeveling.setSpeed(select.value as 'slow' | 'medium' | 'fast');
|
||||
}
|
||||
|
||||
@@ -192,6 +226,7 @@ export class VoiceSettingsComponent {
|
||||
|
||||
onNotificationVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.audioService.setNotificationVolume(parseFloat(input.value));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines,
|
||||
lucideAudioLines
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
@@ -36,10 +37,10 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines,
|
||||
}),
|
||||
lucideAudioLines
|
||||
})
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
templateUrl: './settings.component.html'
|
||||
})
|
||||
/**
|
||||
* Settings page for managing signaling servers and connection preferences.
|
||||
@@ -86,7 +87,7 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
this.serverDirectory.addServer({
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, ''), // Remove trailing slash
|
||||
url: this.newServerUrl.trim().replace(/\/$/, '') // Remove trailing slash
|
||||
});
|
||||
|
||||
// Clear form
|
||||
@@ -96,6 +97,7 @@ export class SettingsComponent implements OnInit {
|
||||
// Test the new server
|
||||
const servers = this.servers();
|
||||
const newServer = servers[servers.length - 1];
|
||||
|
||||
if (newServer) {
|
||||
this.serverDirectory.testServer(newServer.id);
|
||||
}
|
||||
@@ -121,8 +123,10 @@ export class SettingsComponent implements OnInit {
|
||||
/** Load connection settings (auto-reconnect, search scope) from localStorage. */
|
||||
loadConnectionSettings(): void {
|
||||
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings);
|
||||
|
||||
this.autoReconnect = parsed.autoReconnect ?? true;
|
||||
this.searchAllServers = parsed.searchAllServers ?? true;
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
@@ -135,8 +139,8 @@ export class SettingsComponent implements OnInit {
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers,
|
||||
}),
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
@@ -149,10 +153,13 @@ export class SettingsComponent implements OnInit {
|
||||
/** Load voice settings (noise reduction) from localStorage. */
|
||||
loadVoiceSettings(): void {
|
||||
const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings);
|
||||
|
||||
this.noiseReduction = parsed.noiseReduction ?? false;
|
||||
}
|
||||
|
||||
// Sync the live WebRTC state with the persisted preference
|
||||
if (this.noiseReduction !== this.webrtcService.isNoiseReductionEnabled()) {
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||
@@ -173,13 +180,17 @@ export class SettingsComponent implements OnInit {
|
||||
async saveVoiceSettings(): Promise<void> {
|
||||
// Merge into existing voice settings so we don't overwrite device/volume prefs
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
if (raw) existing = JSON.parse(raw);
|
||||
|
||||
if (raw)
|
||||
existing = JSON.parse(raw);
|
||||
} catch {}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_VOICE_SETTINGS,
|
||||
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction }),
|
||||
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction })
|
||||
);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||
@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" />
|
||||
</button>
|
||||
}
|
||||
@@ -16,13 +16,14 @@
|
||||
{{ roomDescription() }}
|
||||
</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" />
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
@if (showMenu()) {
|
||||
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||
>
|
||||
@@ -30,6 +31,7 @@
|
||||
</button>
|
||||
<div class="border-t border-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="logout()"
|
||||
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;">
|
||||
@if (!isAuthed()) {
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||
(click)="goLogin()"
|
||||
title="Login"
|
||||
@@ -57,13 +60,13 @@
|
||||
</button>
|
||||
}
|
||||
@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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</button>
|
||||
}
|
||||
@@ -71,5 +74,14 @@
|
||||
</div>
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
@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>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -17,7 +18,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
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.
|
||||
@@ -48,19 +49,25 @@ export class TitleBarComponent {
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.minimizeWindow) api.minimizeWindow();
|
||||
|
||||
if (api?.minimizeWindow)
|
||||
api.minimizeWindow();
|
||||
}
|
||||
|
||||
/** Maximize or restore the Electron window. */
|
||||
maximize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.maximizeWindow) api.maximizeWindow();
|
||||
|
||||
if (api?.maximizeWindow)
|
||||
api.maximizeWindow();
|
||||
}
|
||||
|
||||
/** Close the Electron window. */
|
||||
close() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.closeWindow) api.closeWindow();
|
||||
|
||||
if (api?.closeWindow)
|
||||
api.closeWindow();
|
||||
}
|
||||
|
||||
/** Navigate to the login page. */
|
||||
@@ -98,9 +105,11 @@ export class TitleBarComponent {
|
||||
// Disconnect from signaling server – this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
} catch {}
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
(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"
|
||||
title="Back to {{ voiceSession()?.serverName }}"
|
||||
>
|
||||
@@ -35,6 +36,7 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
@@ -43,6 +45,7 @@
|
||||
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
@@ -51,6 +54,7 @@
|
||||
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
type="button"
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
@@ -59,6 +63,7 @@
|
||||
|
||||
<button
|
||||
(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"
|
||||
title="Disconnect"
|
||||
>
|
||||
|
||||
@@ -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 { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft,
|
||||
lucideArrowLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -25,12 +26,13 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft,
|
||||
}),
|
||||
lucideArrowLeft
|
||||
})
|
||||
],
|
||||
templateUrl: './floating-voice-controls.component.html'
|
||||
})
|
||||
@@ -86,8 +88,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,8 +112,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,8 +145,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
},
|
||||
isDeafened: false
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
@@ -157,6 +159,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Update user voice state in store
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
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. */
|
||||
getCompactButtonClass(isActive: boolean): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (isActive) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact screen-share button. */
|
||||
getCompactScreenShareClass(): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the mute toggle button. */
|
||||
getMuteButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the deafen toggle button. */
|
||||
getDeafenButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the screen-share toggle button. */
|
||||
getScreenShareButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleFullscreen()"
|
||||
type="button"
|
||||
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
@if (isFullscreen()) {
|
||||
@@ -53,6 +54,7 @@
|
||||
@if (isLocalShare()) {
|
||||
<button
|
||||
(click)="stopSharing()"
|
||||
type="button"
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop sharing"
|
||||
>
|
||||
@@ -61,6 +63,7 @@
|
||||
} @else {
|
||||
<button
|
||||
(click)="stopWatching()"
|
||||
type="button"
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop watching"
|
||||
>
|
||||
|
||||
@@ -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 { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideX,
|
||||
lucideMonitor,
|
||||
lucideMonitor
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -24,10 +25,10 @@ import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
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.
|
||||
@@ -54,9 +55,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||
try {
|
||||
const userId = evt.detail?.userId;
|
||||
if (!userId) return;
|
||||
|
||||
if (!userId)
|
||||
return;
|
||||
|
||||
const stream = this.webrtcService.getRemoteStream(userId);
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
|
||||
|
||||
if (stream && stream.getVideoTracks().length > 0) {
|
||||
if (user) {
|
||||
this.setRemoteStream(stream, user);
|
||||
@@ -77,6 +82,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
// React to screen share stream changes
|
||||
effect(() => {
|
||||
const screenStream = this.webrtcService.screenStream();
|
||||
|
||||
if (screenStream && this.videoRef) {
|
||||
// Local share: always mute to avoid audio feedback
|
||||
this.videoRef.nativeElement.srcObject = screenStream;
|
||||
@@ -97,7 +103,8 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
|
||||
|
||||
// Only check if we're actually watching a remote stream
|
||||
if (!watchingId || !isWatchingRemote) return;
|
||||
if (!watchingId || !isWatchingRemote)
|
||||
return;
|
||||
|
||||
const users = this.onlineUsers();
|
||||
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
|
||||
const stream = this.webrtcService.getRemoteStream(watchingId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
// Stream or video tracks are gone - stop watching
|
||||
this.stopWatching();
|
||||
@@ -153,6 +161,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
/** Enter fullscreen mode, requesting browser fullscreen if available. */
|
||||
enterFullscreen(): void {
|
||||
this.isFullscreen.set(true);
|
||||
|
||||
// Request browser fullscreen if available
|
||||
if (this.videoRef?.nativeElement.requestFullscreen) {
|
||||
this.videoRef.nativeElement.requestFullscreen().catch(() => {
|
||||
@@ -164,6 +173,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
/** Exit fullscreen mode. */
|
||||
exitFullscreen(): void {
|
||||
this.isFullscreen.set(false);
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
@@ -183,10 +193,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = null;
|
||||
}
|
||||
|
||||
this.activeScreenSharer.set(null);
|
||||
this.watchingUserId.set(null);
|
||||
this.hasStream.set(false);
|
||||
this.isLocalShare.set(false);
|
||||
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
}
|
||||
@@ -198,8 +210,10 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
this.activeScreenSharer.set(user);
|
||||
this.watchingUserId.set(user.id || user.oderId || null);
|
||||
this.isLocalShare.set(false);
|
||||
|
||||
if (this.videoRef) {
|
||||
const el = this.videoRef.nativeElement;
|
||||
|
||||
el.srcObject = stream;
|
||||
// For autoplay policies, try muted first, then unmute per volume setting
|
||||
el.muted = true;
|
||||
@@ -208,17 +222,19 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
// After playback starts, apply viewer volume settings
|
||||
el.volume = this.screenVolume() / 100;
|
||||
el.muted = this.screenVolume() === 0;
|
||||
}).catch(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
// If autoplay fails, keep muted to allow play, then apply volume
|
||||
try {
|
||||
el.muted = true;
|
||||
el.volume = 0;
|
||||
el.play().then(() => {
|
||||
el.volume = this.screenVolume() / 100;
|
||||
el.muted = this.screenVolume() === 0;
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
});
|
||||
try {
|
||||
el.muted = true;
|
||||
el.volume = 0;
|
||||
el.play().then(() => {
|
||||
el.volume = this.screenVolume() / 100;
|
||||
el.muted = this.screenVolume() === 0;
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {}
|
||||
});
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
@@ -228,6 +244,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
setLocalStream(stream: MediaStream, user: User): void {
|
||||
this.activeScreenSharer.set(user);
|
||||
this.isLocalShare.set(true);
|
||||
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = stream;
|
||||
// Always mute local share playback
|
||||
@@ -241,10 +258,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
onScreenVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));
|
||||
|
||||
this.screenVolume.set(val);
|
||||
|
||||
if (this.videoRef?.nativeElement) {
|
||||
// Volume applies only to remote streams; keep local share muted
|
||||
const isLocal = this.isLocalShare();
|
||||
|
||||
this.videoRef.nativeElement.volume = isLocal ? 0 : val / 100;
|
||||
this.videoRef.nativeElement.muted = isLocal ? true : val === 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
||||
|
||||
@@ -10,15 +10,12 @@ export interface PlaybackOptions {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoicePlaybackService {
|
||||
private voiceLeveling = inject(VoiceLevelingService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
||||
private pendingRemoteStreams = 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 {
|
||||
if (!options.isConnected) {
|
||||
this.pendingRemoteStreams.set(peerId, stream);
|
||||
@@ -36,6 +33,7 @@ export class VoicePlaybackService {
|
||||
|
||||
// Start playback immediately with the raw stream
|
||||
const audio = new Audio();
|
||||
|
||||
audio.srcObject = stream;
|
||||
audio.autoplay = true;
|
||||
audio.volume = options.outputVolume;
|
||||
@@ -47,10 +45,12 @@ export class VoicePlaybackService {
|
||||
if (this.voiceLeveling.enabled()) {
|
||||
this.voiceLeveling.enable(peerId, stream).then((leveledStream) => {
|
||||
const currentAudio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (currentAudio && leveledStream !== stream) {
|
||||
currentAudio.srcObject = leveledStream;
|
||||
}
|
||||
}).catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,18 +62,25 @@ export class VoicePlaybackService {
|
||||
}
|
||||
|
||||
playPendingStreams(options: PlaybackOptions): void {
|
||||
if (!options.isConnected) return;
|
||||
if (!options.isConnected)
|
||||
return;
|
||||
|
||||
this.pendingRemoteStreams.forEach((stream, peerId) => this.handleRemoteStream(peerId, stream, options));
|
||||
this.pendingRemoteStreams.clear();
|
||||
}
|
||||
|
||||
ensureAllRemoteStreamsPlaying(options: PlaybackOptions): void {
|
||||
if (!options.isConnected) return;
|
||||
if (!options.isConnected)
|
||||
return;
|
||||
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
for (const peerId of peers) {
|
||||
const stream = this.webrtc.getRemoteStream(peerId);
|
||||
|
||||
if (stream && this.hasAudio(stream)) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
if (!trackedRaw || trackedRaw !== stream) {
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
@@ -87,6 +94,7 @@ export class VoicePlaybackService {
|
||||
try {
|
||||
const leveledStream = await this.voiceLeveling.enable(peerId, rawStream);
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (audio && leveledStream !== rawStream) {
|
||||
audio.srcObject = leveledStream;
|
||||
}
|
||||
@@ -94,13 +102,16 @@ export class VoicePlaybackService {
|
||||
}
|
||||
} else {
|
||||
this.voiceLeveling.disableAll();
|
||||
|
||||
for (const [peerId, rawStream] of this.rawRemoteStreams) {
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (audio) {
|
||||
audio.srcObject = rawStream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateOutputVolume(options.outputVolume);
|
||||
this.updateDeafened(options.isDeafened);
|
||||
}
|
||||
@@ -118,9 +129,12 @@ export class VoicePlaybackService {
|
||||
}
|
||||
|
||||
applyOutputDevice(deviceId: string): void {
|
||||
if (!deviceId) return;
|
||||
if (!deviceId)
|
||||
return;
|
||||
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
const anyAudio = audio as any;
|
||||
|
||||
if (typeof anyAudio.setSinkId === 'function') {
|
||||
anyAudio.setSinkId(deviceId).catch(() => {});
|
||||
}
|
||||
@@ -143,6 +157,7 @@ export class VoicePlaybackService {
|
||||
|
||||
private removeAudioElement(peerId: string): void {
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
|
||||
if (audio) {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="text-xs text-destructive">{{
|
||||
connectionErrorMessage() || 'Connection error'
|
||||
}}</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
</p>
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button (click)="toggleMute()" [class]="getMuteButtonClass()">
|
||||
<button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()">
|
||||
@if (isMuted()) {
|
||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
@@ -49,12 +49,12 @@
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
|
||||
<button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
|
||||
<button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
@@ -64,6 +64,7 @@
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
type="button"
|
||||
(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"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
computed,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones,
|
||||
lucideHeadphones
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
@@ -55,10 +54,10 @@ interface AudioDevice {
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones,
|
||||
}),
|
||||
lucideHeadphones
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-controls.component.html',
|
||||
templateUrl: './voice-controls.component.html'
|
||||
})
|
||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
@@ -98,7 +97,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
isConnected: this.isConnected(),
|
||||
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(
|
||||
({ peerId, stream }) => {
|
||||
this.voicePlayback.handleRemoteStream(peerId, stream, this.playbackOptions());
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Listen for live voice-leveling toggle changes so we can
|
||||
// rebuild all remote Audio elements immediately (no reconnect).
|
||||
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
|
||||
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
||||
const options = this.playbackOptions();
|
||||
|
||||
this.voicePlayback.playPendingStreams(options);
|
||||
// Also ensure all remote streams from connected peers are playing
|
||||
// This handles the case where streams were received while voice was "connected"
|
||||
@@ -158,24 +158,27 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label })),
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.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> {
|
||||
try {
|
||||
// Require signaling connectivity first
|
||||
const ok = await this.webrtcService.ensureSignalingConnected();
|
||||
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@@ -188,14 +191,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
noiseSuppression: true
|
||||
}
|
||||
});
|
||||
|
||||
await this.webrtcService.setLocalStream(stream);
|
||||
|
||||
// Track local mic for voice-activity visualisation
|
||||
const userId = this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.trackLocalMic(userId, stream);
|
||||
}
|
||||
@@ -204,6 +208,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
const room = this.currentRoom();
|
||||
const roomId = this.currentUser()?.voiceState?.roomId || room?.id;
|
||||
const serverId = room?.id;
|
||||
|
||||
this.webrtcService.startVoiceHeartbeat(roomId, serverId);
|
||||
|
||||
// Broadcast voice state to other users
|
||||
@@ -216,8 +221,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
roomId,
|
||||
serverId,
|
||||
},
|
||||
serverId
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
this.saveSettings();
|
||||
} catch (error) {}
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
// Retry connection when there's a connection error
|
||||
@@ -248,8 +253,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
serverId: this.currentRoom()?.id,
|
||||
},
|
||||
serverId: this.currentRoom()?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
@@ -259,6 +264,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Untrack local mic from voice-activity visualisation
|
||||
const userId = this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
@@ -271,6 +277,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.voicePlayback.teardownAll();
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
@@ -280,9 +287,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
},
|
||||
}),
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,8 +313,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -331,8 +338,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -344,7 +351,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
try {
|
||||
await this.webrtcService.startScreenShare(this.includeSystemAudio());
|
||||
this.isScreenSharing.set(true);
|
||||
} catch (error) {}
|
||||
} catch (_error) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,17 +365,21 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedInputDevice.set(select.value);
|
||||
|
||||
// Reconnect with new device if connected
|
||||
if (this.isConnected()) {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.applyOutputDevice();
|
||||
this.saveSettings();
|
||||
@@ -376,12 +387,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
@@ -391,6 +404,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
onLatencyProfileChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const profile = select.value as 'low' | 'balanced' | 'high';
|
||||
|
||||
this.latencyProfile.set(profile);
|
||||
this.webrtcService.setLatencyProfile(profile);
|
||||
this.saveSettings();
|
||||
@@ -399,6 +413,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const kbps = parseInt(input.value, 10);
|
||||
|
||||
this.audioBitrate.set(kbps);
|
||||
this.webrtcService.setAudioBitrate(kbps);
|
||||
this.saveSettings();
|
||||
@@ -406,12 +421,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
async onNoiseReductionChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.noiseReduction.set(!!input.checked);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveSettings();
|
||||
@@ -420,7 +437,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private loadSettings(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
if (!raw) return;
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const settings = JSON.parse(raw) as {
|
||||
inputDevice?: string;
|
||||
outputDevice?: string;
|
||||
@@ -431,14 +451,28 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
includeSystemAudio?: boolean;
|
||||
noiseReduction?: boolean;
|
||||
};
|
||||
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
|
||||
if (settings.outputDevice) 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 (settings.inputDevice)
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
|
||||
if (settings.outputDevice)
|
||||
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 {}
|
||||
@@ -454,8 +488,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
audioBitrate: this.audioBitrate(),
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
noiseReduction: this.noiseReduction(),
|
||||
noiseReduction: this.noiseReduction()
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
|
||||
} catch {}
|
||||
}
|
||||
@@ -474,34 +509,43 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async applyOutputDevice(): Promise<void> {
|
||||
const deviceId = this.selectedOutputDevice();
|
||||
if (!deviceId) return;
|
||||
|
||||
if (!deviceId)
|
||||
return;
|
||||
|
||||
this.voicePlayback.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
getMuteButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
|
||||
getDeafenButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
|
||||
getScreenShareButtonClass(): string {
|
||||
const base =
|
||||
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,15 @@ import { Component, input, output, HostListener } from '@angular/core';
|
||||
standalone: true,
|
||||
template: `
|
||||
<!-- 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 -->
|
||||
<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()">
|
||||
@@ -35,13 +43,15 @@ import { Component, input, output, HostListener } from '@angular/core';
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<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"
|
||||
>
|
||||
{{ cancelLabel() }}
|
||||
</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.bg-primary]="variant() === 'primary'"
|
||||
[class.text-primary-foreground]="variant() === 'primary'"
|
||||
@@ -55,7 +65,7 @@ import { Component, input, output, HostListener } from '@angular/core';
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`:host { display: contents; }`],
|
||||
styles: [':host { display: contents; }']
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
/** Dialog title. */
|
||||
@@ -69,12 +79,12 @@ export class ConfirmDialogComponent {
|
||||
/** Tailwind width class for the dialog. */
|
||||
widthClass = input<string>('w-[320px]');
|
||||
/** Emitted when the user confirms. */
|
||||
confirmed = output<void>();
|
||||
confirmed = output<undefined>();
|
||||
/** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */
|
||||
cancelled = output<void>();
|
||||
cancelled = output<undefined>();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.cancelled.emit();
|
||||
this.cancelled.emit(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,15 @@ import { Component, input, output, HostListener } from '@angular/core';
|
||||
standalone: true,
|
||||
template: `
|
||||
<!-- 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 -->
|
||||
<div
|
||||
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 {
|
||||
@apply px-3 py-1.5 text-sm text-muted-foreground;
|
||||
}
|
||||
`,
|
||||
],
|
||||
`
|
||||
]
|
||||
})
|
||||
export class ContextMenuComponent {
|
||||
/** Horizontal position (px from left). */
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
x = input.required<number>();
|
||||
/** Vertical position (px from top). */
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
y = input.required<number>();
|
||||
/** Tailwind width class for the panel (default `w-48`). */
|
||||
width = input<string>('w-48');
|
||||
/** Emitted when the menu should close (backdrop click or Escape). */
|
||||
closed = output<void>();
|
||||
closed = output<undefined>();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.closed.emit();
|
||||
this.closed.emit(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { Component, input } from '@angular/core';
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`:host { display: contents; }`],
|
||||
styles: [':host { display: contents; }']
|
||||
})
|
||||
export class UserAvatarComponent {
|
||||
/** Display name — first character is used as fallback initial. */
|
||||
@@ -48,7 +48,8 @@ export class UserAvatarComponent {
|
||||
|
||||
/** Compute the first-letter initial. */
|
||||
initial(): string {
|
||||
return this.name()?.charAt(0)?.toUpperCase() ?? '?';
|
||||
return this.name()?.charAt(0)
|
||||
?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
/** Map size token to Tailwind dimension classes. */
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface AppState {
|
||||
export const reducers: ActionReducerMap<AppState> = {
|
||||
messages: messagesReducer,
|
||||
users: usersReducer,
|
||||
rooms: roomsReducer,
|
||||
rooms: roomsReducer
|
||||
};
|
||||
|
||||
/** Meta-reducers (e.g. logging) enabled only in development builds. */
|
||||
@@ -44,7 +44,7 @@ export {
|
||||
selectCurrentRoomMessages,
|
||||
selectMessageById,
|
||||
selectMessagesLoading,
|
||||
selectCurrentRoomId as selectMessagesCurrentRoomId,
|
||||
selectCurrentRoomId as selectMessagesCurrentRoomId
|
||||
} from './messages/messages.selectors';
|
||||
|
||||
export {
|
||||
@@ -56,7 +56,7 @@ export {
|
||||
selectOnlineUsers,
|
||||
selectHostId,
|
||||
selectIsCurrentUserHost as selectIsCurrentUserHostFromUsers,
|
||||
selectBannedUsers,
|
||||
selectBannedUsers
|
||||
} from './users/users.selectors';
|
||||
|
||||
export {
|
||||
@@ -66,7 +66,7 @@ export {
|
||||
selectRoomSettings,
|
||||
selectIsCurrentUserHost,
|
||||
selectSavedRooms,
|
||||
selectRoomsLoading,
|
||||
selectRoomsLoading
|
||||
} from './rooms/rooms.selectors';
|
||||
|
||||
// Re-export effects
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
buildLocalInventoryMap,
|
||||
findMissingIds,
|
||||
hydrateMessage,
|
||||
mergeIncomingMessage,
|
||||
mergeIncomingMessage
|
||||
} from './messages.helpers';
|
||||
|
||||
/** Shared context injected into each handler function. */
|
||||
@@ -50,18 +50,21 @@ type MessageHandler = (
|
||||
*/
|
||||
function handleInventoryRequest(
|
||||
event: any,
|
||||
{ db, webrtc }: IncomingMessageContext,
|
||||
{ db, webrtc }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const { roomId, fromPeerId } = event;
|
||||
if (!roomId || !fromPeerId) return EMPTY;
|
||||
|
||||
if (!roomId || !fromPeerId)
|
||||
return EMPTY;
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
|
||||
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)) {
|
||||
webrtc.sendToPeer(fromPeerId, {
|
||||
@@ -69,10 +72,10 @@ function handleInventoryRequest(
|
||||
roomId,
|
||||
items: chunk,
|
||||
total: items.length,
|
||||
index: 0,
|
||||
index: 0
|
||||
} as any);
|
||||
}
|
||||
})(),
|
||||
})()
|
||||
).pipe(mergeMap(() => EMPTY));
|
||||
}
|
||||
|
||||
@@ -82,10 +85,12 @@ function handleInventoryRequest(
|
||||
*/
|
||||
function handleInventory(
|
||||
event: any,
|
||||
{ db, webrtc }: IncomingMessageContext,
|
||||
{ db, webrtc }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const { roomId, fromPeerId, items } = event;
|
||||
if (!roomId || !Array.isArray(items) || !fromPeerId) return EMPTY;
|
||||
|
||||
if (!roomId || !Array.isArray(items) || !fromPeerId)
|
||||
return EMPTY;
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
@@ -97,10 +102,10 @@ function handleInventory(
|
||||
webrtc.sendToPeer(fromPeerId, {
|
||||
type: 'chat-sync-request-ids',
|
||||
roomId,
|
||||
ids: chunk,
|
||||
ids: chunk
|
||||
} as any);
|
||||
}
|
||||
})(),
|
||||
})()
|
||||
).pipe(mergeMap(() => EMPTY));
|
||||
}
|
||||
|
||||
@@ -110,33 +115,36 @@ function handleInventory(
|
||||
*/
|
||||
function handleSyncRequestIds(
|
||||
event: any,
|
||||
{ db, webrtc, attachments }: IncomingMessageContext,
|
||||
{ db, webrtc, attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const { roomId, ids, fromPeerId } = event;
|
||||
if (!Array.isArray(ids) || !fromPeerId) return EMPTY;
|
||||
|
||||
if (!Array.isArray(ids) || !fromPeerId)
|
||||
return EMPTY;
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
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(
|
||||
(msg): msg is Message => !!msg,
|
||||
(msg): msg is Message => !!msg
|
||||
);
|
||||
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 attachmentMetas =
|
||||
attachments.getAttachmentMetasForMessages(msgIds);
|
||||
|
||||
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
|
||||
const chunkAttachments: Record<string, any> = {};
|
||||
for (const m of chunk) {
|
||||
if (attachmentMetas[m.id])
|
||||
chunkAttachments[m.id] = attachmentMetas[m.id];
|
||||
|
||||
for (const hydratedMessage of chunk) {
|
||||
if (attachmentMetas[hydratedMessage.id])
|
||||
chunkAttachments[hydratedMessage.id] = attachmentMetas[hydratedMessage.id];
|
||||
}
|
||||
|
||||
webrtc.sendToPeer(fromPeerId, {
|
||||
type: 'chat-sync-batch',
|
||||
roomId: roomId || '',
|
||||
@@ -144,10 +152,10 @@ function handleSyncRequestIds(
|
||||
attachments:
|
||||
Object.keys(chunkAttachments).length > 0
|
||||
? chunkAttachments
|
||||
: undefined,
|
||||
: undefined
|
||||
} as any);
|
||||
}
|
||||
})(),
|
||||
})()
|
||||
).pipe(mergeMap(() => EMPTY));
|
||||
}
|
||||
|
||||
@@ -158,9 +166,10 @@ function handleSyncRequestIds(
|
||||
*/
|
||||
function handleSyncBatch(
|
||||
event: any,
|
||||
{ db, attachments }: IncomingMessageContext,
|
||||
{ db, attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
if (!Array.isArray(event.messages)) return EMPTY;
|
||||
if (!Array.isArray(event.messages))
|
||||
return EMPTY;
|
||||
|
||||
if (event.attachments && typeof event.attachments === 'object') {
|
||||
attachments.registerSyncedAttachments(event.attachments);
|
||||
@@ -170,8 +179,8 @@ function handleSyncBatch(
|
||||
mergeMap((toUpsert) =>
|
||||
toUpsert.length > 0
|
||||
? of(MessagesActions.syncMessages({ messages: toUpsert }))
|
||||
: EMPTY,
|
||||
),
|
||||
: EMPTY
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,13 +188,15 @@ function handleSyncBatch(
|
||||
async function processSyncBatch(
|
||||
event: any,
|
||||
db: DatabaseService,
|
||||
attachments: AttachmentService,
|
||||
attachments: AttachmentService
|
||||
): Promise<Message[]> {
|
||||
const toUpsert: Message[] = [];
|
||||
|
||||
for (const incoming of event.messages as Message[]) {
|
||||
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
||||
if (changed) toUpsert.push(message);
|
||||
|
||||
if (changed)
|
||||
toUpsert.push(message);
|
||||
}
|
||||
|
||||
if (event.attachments && event.fromPeerId) {
|
||||
@@ -198,19 +209,22 @@ async function processSyncBatch(
|
||||
/** Auto-requests any unavailable image attachments from any connected peer. */
|
||||
function requestMissingImages(
|
||||
attachmentMap: Record<string, any[]>,
|
||||
attachments: AttachmentService,
|
||||
attachments: AttachmentService
|
||||
): void {
|
||||
for (const [msgId, metas] of Object.entries(attachmentMap)) {
|
||||
for (const meta of metas) {
|
||||
if (!meta.isImage) continue;
|
||||
if (!meta.isImage)
|
||||
continue;
|
||||
|
||||
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 (
|
||||
att &&
|
||||
!att.available &&
|
||||
!(att.receivedBytes && att.receivedBytes > 0)
|
||||
matchingAttachment &&
|
||||
!matchingAttachment.available &&
|
||||
!(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. */
|
||||
function handleChatMessage(
|
||||
event: any,
|
||||
{ db, currentUser }: IncomingMessageContext,
|
||||
{ db, currentUser }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const msg = event.message;
|
||||
if (!msg) return EMPTY;
|
||||
|
||||
if (!msg)
|
||||
return EMPTY;
|
||||
|
||||
// Skip our own messages (reflected via server relay)
|
||||
const isOwnMessage =
|
||||
msg.senderId === currentUser?.id ||
|
||||
msg.senderId === currentUser?.oderId;
|
||||
if (isOwnMessage) return EMPTY;
|
||||
|
||||
if (isOwnMessage)
|
||||
return EMPTY;
|
||||
|
||||
db.saveMessage(msg);
|
||||
return of(MessagesActions.receiveMessage({ message: msg }));
|
||||
@@ -237,42 +255,45 @@ function handleChatMessage(
|
||||
/** Applies a remote message edit to the local DB and store. */
|
||||
function handleMessageEdited(
|
||||
event: any,
|
||||
{ db }: IncomingMessageContext,
|
||||
{ db }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
if (!event.messageId || !event.content) return EMPTY;
|
||||
if (!event.messageId || !event.content)
|
||||
return EMPTY;
|
||||
|
||||
db.updateMessage(event.messageId, {
|
||||
content: event.content,
|
||||
editedAt: event.editedAt,
|
||||
editedAt: event.editedAt
|
||||
});
|
||||
return of(
|
||||
MessagesActions.editMessageSuccess({
|
||||
messageId: event.messageId,
|
||||
content: event.content,
|
||||
editedAt: event.editedAt,
|
||||
}),
|
||||
editedAt: event.editedAt
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies a remote message deletion to the local DB and store. */
|
||||
function handleMessageDeleted(
|
||||
event: any,
|
||||
{ db }: IncomingMessageContext,
|
||||
{ db }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
if (!event.messageId) return EMPTY;
|
||||
if (!event.messageId)
|
||||
return EMPTY;
|
||||
|
||||
db.deleteMessage(event.messageId);
|
||||
return of(
|
||||
MessagesActions.deleteMessageSuccess({ messageId: event.messageId }),
|
||||
MessagesActions.deleteMessageSuccess({ messageId: event.messageId })
|
||||
);
|
||||
}
|
||||
|
||||
/** Saves an incoming reaction to DB and updates the store. */
|
||||
function handleReactionAdded(
|
||||
event: any,
|
||||
{ db }: IncomingMessageContext,
|
||||
{ db }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
if (!event.messageId || !event.reaction) return EMPTY;
|
||||
if (!event.messageId || !event.reaction)
|
||||
return EMPTY;
|
||||
|
||||
db.saveReaction(event.reaction);
|
||||
return of(MessagesActions.addReactionSuccess({ reaction: event.reaction }));
|
||||
@@ -281,23 +302,24 @@ function handleReactionAdded(
|
||||
/** Removes a reaction from DB and updates the store. */
|
||||
function handleReactionRemoved(
|
||||
event: any,
|
||||
{ db }: IncomingMessageContext,
|
||||
{ db }: IncomingMessageContext
|
||||
): 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);
|
||||
return of(
|
||||
MessagesActions.removeReactionSuccess({
|
||||
messageId: event.messageId,
|
||||
oderId: event.oderId,
|
||||
emoji: event.emoji,
|
||||
}),
|
||||
emoji: event.emoji
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function handleFileAnnounce(
|
||||
event: any,
|
||||
{ attachments }: IncomingMessageContext,
|
||||
{ attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
attachments.handleFileAnnounce(event);
|
||||
return EMPTY;
|
||||
@@ -305,7 +327,7 @@ function handleFileAnnounce(
|
||||
|
||||
function handleFileChunk(
|
||||
event: any,
|
||||
{ attachments }: IncomingMessageContext,
|
||||
{ attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
attachments.handleFileChunk(event);
|
||||
return EMPTY;
|
||||
@@ -313,7 +335,7 @@ function handleFileChunk(
|
||||
|
||||
function handleFileRequest(
|
||||
event: any,
|
||||
{ attachments }: IncomingMessageContext,
|
||||
{ attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
attachments.handleFileRequest(event);
|
||||
return EMPTY;
|
||||
@@ -321,7 +343,7 @@ function handleFileRequest(
|
||||
|
||||
function handleFileCancel(
|
||||
event: any,
|
||||
{ attachments }: IncomingMessageContext,
|
||||
{ attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
attachments.handleFileCancel(event);
|
||||
return EMPTY;
|
||||
@@ -329,7 +351,7 @@ function handleFileCancel(
|
||||
|
||||
function handleFileNotFound(
|
||||
event: any,
|
||||
{ attachments }: IncomingMessageContext,
|
||||
{ attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
attachments.handleFileNotFound(event);
|
||||
return EMPTY;
|
||||
@@ -341,21 +363,21 @@ function handleFileNotFound(
|
||||
*/
|
||||
function handleSyncSummary(
|
||||
event: any,
|
||||
{ db, webrtc, currentRoom }: IncomingMessageContext,
|
||||
{ db, webrtc, currentRoom }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
if (!currentRoom) return EMPTY;
|
||||
if (!currentRoom)
|
||||
return EMPTY;
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
|
||||
const localCount = local.length;
|
||||
const localLastUpdated = local.reduce(
|
||||
(max, m) => Math.max(max, m.editedAt || m.timestamp || 0),
|
||||
0,
|
||||
(maxTimestamp, message) => Math.max(maxTimestamp, message.editedAt || message.timestamp || 0),
|
||||
0
|
||||
);
|
||||
const remoteLastUpdated = event.lastUpdated || 0;
|
||||
const remoteCount = event.count || 0;
|
||||
|
||||
const identical =
|
||||
localLastUpdated === remoteLastUpdated && localCount === remoteCount;
|
||||
const needsSync =
|
||||
@@ -365,38 +387,41 @@ function handleSyncSummary(
|
||||
if (!identical && needsSync && event.fromPeerId) {
|
||||
webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'chat-sync-request',
|
||||
roomId: currentRoom.id,
|
||||
roomId: currentRoom.id
|
||||
} as any);
|
||||
}
|
||||
})(),
|
||||
})()
|
||||
).pipe(mergeMap(() => EMPTY));
|
||||
}
|
||||
|
||||
/** Responds to a peer's full sync request by sending all local messages. */
|
||||
function handleSyncRequest(
|
||||
event: any,
|
||||
{ db, webrtc, currentRoom }: IncomingMessageContext,
|
||||
{ db, webrtc, currentRoom }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
if (!currentRoom || !event.fromPeerId) return EMPTY;
|
||||
if (!currentRoom || !event.fromPeerId)
|
||||
return EMPTY;
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
|
||||
|
||||
webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'chat-sync-full',
|
||||
roomId: currentRoom.id,
|
||||
messages: all,
|
||||
messages: all
|
||||
} as any);
|
||||
})(),
|
||||
})()
|
||||
).pipe(mergeMap(() => EMPTY));
|
||||
}
|
||||
|
||||
/** Merges a full message dump from a peer into the local DB and store. */
|
||||
function handleSyncFull(
|
||||
event: any,
|
||||
{ db }: IncomingMessageContext,
|
||||
{ db }: IncomingMessageContext
|
||||
): 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));
|
||||
return of(MessagesActions.syncMessages({ messages: event.messages }));
|
||||
@@ -429,7 +454,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
|
||||
// Legacy sync handshake
|
||||
'chat-sync-summary': handleSyncSummary,
|
||||
'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(
|
||||
event: any,
|
||||
ctx: IncomingMessageContext,
|
||||
ctx: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const handler = HANDLER_MAP[event.type];
|
||||
|
||||
return handler ? handler(event, ctx) : EMPTY;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Extracted from the monolithic MessagesEffects to keep each
|
||||
* class focused on a single concern.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
exhaustMap,
|
||||
switchMap,
|
||||
repeat,
|
||||
takeUntil,
|
||||
takeUntil
|
||||
} from 'rxjs/operators';
|
||||
import { MessagesActions } from './messages.actions';
|
||||
import { RoomsActions } from '../rooms/rooms.actions';
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
SYNC_POLL_FAST_MS,
|
||||
SYNC_POLL_SLOW_MS,
|
||||
SYNC_TIMEOUT_MS,
|
||||
getLatestTimestamp,
|
||||
getLatestTimestamp
|
||||
} from './messages.helpers';
|
||||
|
||||
@Injectable()
|
||||
@@ -61,28 +62,31 @@ export class MessagesSyncEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([peerId, room]) => {
|
||||
if (!room) return EMPTY;
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
|
||||
return from(
|
||||
this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0),
|
||||
this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0)
|
||||
).pipe(
|
||||
tap((messages) => {
|
||||
const count = messages.length;
|
||||
const lastUpdated = getLatestTimestamp(messages);
|
||||
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
type: 'chat-sync-summary',
|
||||
roomId: room.id,
|
||||
count,
|
||||
lastUpdated,
|
||||
lastUpdated
|
||||
} as any);
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
type: 'chat-inventory-request',
|
||||
roomId: room.id,
|
||||
roomId: room.id
|
||||
} as any);
|
||||
}),
|
||||
})
|
||||
);
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ dispatch: false },
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -96,35 +100,38 @@ export class MessagesSyncEffects {
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ room }, currentRoom]) => {
|
||||
const activeRoom = currentRoom || room;
|
||||
if (!activeRoom) return EMPTY;
|
||||
|
||||
if (!activeRoom)
|
||||
return EMPTY;
|
||||
|
||||
return from(
|
||||
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0),
|
||||
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)
|
||||
).pipe(
|
||||
tap((messages) => {
|
||||
const count = messages.length;
|
||||
const lastUpdated = getLatestTimestamp(messages);
|
||||
|
||||
for (const pid of this.webrtc.getConnectedPeers()) {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-sync-summary',
|
||||
roomId: activeRoom.id,
|
||||
count,
|
||||
lastUpdated,
|
||||
lastUpdated
|
||||
} as any);
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-inventory-request',
|
||||
roomId: activeRoom.id,
|
||||
roomId: activeRoom.id
|
||||
} as any);
|
||||
} catch {
|
||||
/* peer may have disconnected */
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
);
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ dispatch: false },
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -136,44 +143,46 @@ export class MessagesSyncEffects {
|
||||
repeat({
|
||||
delay: () =>
|
||||
timer(
|
||||
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS,
|
||||
),
|
||||
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
|
||||
)
|
||||
}),
|
||||
takeUntil(this.syncReset$),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
filter(
|
||||
([, room]) =>
|
||||
!!room && this.webrtc.getConnectedPeers().length > 0,
|
||||
!!room && this.webrtc.getConnectedPeers().length > 0
|
||||
),
|
||||
exhaustMap(([, room]) => {
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (!room || peers.length === 0) {
|
||||
return of(MessagesActions.syncComplete());
|
||||
}
|
||||
|
||||
return from(
|
||||
this.db.getMessages(room.id, INVENTORY_LIMIT, 0),
|
||||
this.db.getMessages(room.id, INVENTORY_LIMIT, 0)
|
||||
).pipe(
|
||||
map(() => {
|
||||
for (const pid of peers) {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-inventory-request',
|
||||
roomId: room.id,
|
||||
roomId: room.id
|
||||
} as any);
|
||||
} catch {
|
||||
/* peer may have disconnected */
|
||||
}
|
||||
}
|
||||
|
||||
return MessagesActions.startSync();
|
||||
}),
|
||||
catchError(() => {
|
||||
this.lastSyncClean = false;
|
||||
return of(MessagesActions.syncComplete());
|
||||
}),
|
||||
})
|
||||
);
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -184,15 +193,15 @@ export class MessagesSyncEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.startSync),
|
||||
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)),
|
||||
filter(([, syncing]) => syncing),
|
||||
map(() => {
|
||||
this.lastSyncClean = true;
|
||||
return MessagesActions.syncComplete();
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -204,8 +213,8 @@ export class MessagesSyncEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
tap(() => {
|
||||
this.lastSyncClean = false;
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ dispatch: false },
|
||||
{ dispatch: false }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,6 @@ export const MessagesActions = createActionGroup({
|
||||
'Sync Complete': emptyProps(),
|
||||
|
||||
/** Removes all messages from the store (e.g. when leaving a room). */
|
||||
'Clear Messages': emptyProps(),
|
||||
},
|
||||
'Clear Messages': emptyProps()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* The giant `incomingMessages$` switch-case has been replaced by a
|
||||
* handler registry in `messages-incoming.handlers.ts`.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -25,7 +26,7 @@ import { Message, Reaction } from '../../core/models';
|
||||
import { hydrateMessages } from './messages.helpers';
|
||||
import {
|
||||
dispatchIncomingMessage,
|
||||
IncomingMessageContext,
|
||||
IncomingMessageContext
|
||||
} from './messages-incoming.handlers';
|
||||
|
||||
@Injectable()
|
||||
@@ -45,14 +46,15 @@ export class MessagesEffects {
|
||||
from(this.db.getMessages(roomId)).pipe(
|
||||
mergeMap(async (messages) => {
|
||||
const hydrated = await hydrateMessages(messages, this.db);
|
||||
|
||||
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
||||
}),
|
||||
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. */
|
||||
@@ -61,7 +63,7 @@ export class MessagesEffects {
|
||||
ofType(MessagesActions.sendMessage),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
@@ -78,7 +80,7 @@ export class MessagesEffects {
|
||||
timestamp: this.timeSync.now(),
|
||||
reactions: [],
|
||||
isDeleted: false,
|
||||
replyToId,
|
||||
replyToId
|
||||
};
|
||||
|
||||
this.db.saveMessage(message);
|
||||
@@ -87,9 +89,9 @@ export class MessagesEffects {
|
||||
return of(MessagesActions.sendMessageSuccess({ message }));
|
||||
}),
|
||||
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. */
|
||||
@@ -107,22 +109,24 @@ export class MessagesEffects {
|
||||
if (!existing) {
|
||||
return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
|
||||
}
|
||||
|
||||
if (existing.senderId !== currentUser.id) {
|
||||
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
|
||||
}
|
||||
|
||||
const editedAt = this.timeSync.now();
|
||||
|
||||
this.db.updateMessage(messageId, { content, editedAt });
|
||||
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
|
||||
|
||||
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
|
||||
}),
|
||||
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. */
|
||||
@@ -140,6 +144,7 @@ export class MessagesEffects {
|
||||
if (!existing) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
|
||||
}
|
||||
|
||||
if (existing.senderId !== currentUser.id) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
|
||||
}
|
||||
@@ -150,11 +155,11 @@ export class MessagesEffects {
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.deleteMessageFailure({ error: error.message })),
|
||||
),
|
||||
of(MessagesActions.deleteMessageFailure({ error: error.message }))
|
||||
)
|
||||
);
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Soft-deletes any message (admin+ only). */
|
||||
@@ -182,9 +187,9 @@ export class MessagesEffects {
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
}),
|
||||
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. */
|
||||
@@ -193,7 +198,8 @@ export class MessagesEffects {
|
||||
ofType(MessagesActions.addReaction),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||
if (!currentUser) return EMPTY;
|
||||
if (!currentUser)
|
||||
return EMPTY;
|
||||
|
||||
const reaction: Reaction = {
|
||||
id: uuidv4(),
|
||||
@@ -201,15 +207,15 @@ export class MessagesEffects {
|
||||
oderId: currentUser.id,
|
||||
userId: currentUser.id,
|
||||
emoji,
|
||||
timestamp: this.timeSync.now(),
|
||||
timestamp: this.timeSync.now()
|
||||
};
|
||||
|
||||
this.db.saveReaction(reaction);
|
||||
this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction });
|
||||
|
||||
return of(MessagesActions.addReactionSuccess({ reaction }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Removes the current user's reaction from a message, deletes from DB, and broadcasts. */
|
||||
@@ -218,25 +224,26 @@ export class MessagesEffects {
|
||||
ofType(MessagesActions.removeReaction),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||
if (!currentUser) return EMPTY;
|
||||
if (!currentUser)
|
||||
return EMPTY;
|
||||
|
||||
this.db.removeReaction(messageId, currentUser.id, emoji);
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'reaction-removed',
|
||||
messageId,
|
||||
oderId: currentUser.id,
|
||||
emoji,
|
||||
emoji
|
||||
});
|
||||
|
||||
return of(
|
||||
MessagesActions.removeReactionSuccess({
|
||||
messageId,
|
||||
oderId: currentUser.id,
|
||||
emoji,
|
||||
}),
|
||||
emoji
|
||||
})
|
||||
);
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -247,7 +254,7 @@ export class MessagesEffects {
|
||||
this.webrtc.onMessageReceived.pipe(
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
|
||||
const ctx: IncomingMessageContext = {
|
||||
@@ -255,10 +262,11 @@ export class MessagesEffects {
|
||||
webrtc: this.webrtc,
|
||||
attachments: this.attachments,
|
||||
currentUser,
|
||||
currentRoom,
|
||||
currentRoom
|
||||
};
|
||||
|
||||
return dispatchIncomingMessage(event, ctx);
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,32 +34,35 @@ export function getMessageTimestamp(msg: Message): number {
|
||||
export function getLatestTimestamp(messages: Message[]): number {
|
||||
return messages.reduce(
|
||||
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** Splits an array into chunks of the given size. */
|
||||
export function chunkArray<T>(items: T[], size: number): 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;
|
||||
}
|
||||
|
||||
/** Hydrates a single message with its reactions from the database. */
|
||||
export async function hydrateMessage(
|
||||
msg: Message,
|
||||
db: DatabaseService,
|
||||
db: DatabaseService
|
||||
): Promise<Message> {
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
|
||||
return reactions.length > 0 ? { ...msg, reactions } : msg;
|
||||
}
|
||||
|
||||
/** Hydrates an array of messages with their reactions. */
|
||||
export async function hydrateMessages(
|
||||
messages: Message[],
|
||||
db: DatabaseService,
|
||||
db: DatabaseService
|
||||
): Promise<Message[]> {
|
||||
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. */
|
||||
export async function buildInventoryItem(
|
||||
msg: Message,
|
||||
db: DatabaseService,
|
||||
db: DatabaseService
|
||||
): Promise<InventoryItem> {
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
|
||||
return { id: msg.id, ts: getMessageTimestamp(msg), rc: reactions.length };
|
||||
}
|
||||
|
||||
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
|
||||
export async function buildLocalInventoryMap(
|
||||
messages: Message[],
|
||||
db: DatabaseService,
|
||||
db: DatabaseService
|
||||
): Promise<Map<string, { ts: number; rc: number }>> {
|
||||
const map = new Map<string, { ts: number; rc: number }>();
|
||||
|
||||
await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
|
||||
map.set(msg.id, { ts: getMessageTimestamp(msg), rc: reactions.length });
|
||||
}),
|
||||
})
|
||||
);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
||||
export function findMissingIds(
|
||||
remoteItems: ReadonlyArray<{ id: string; ts: number; rc?: number }>,
|
||||
localMap: ReadonlyMap<string, { ts: number; rc: number }>,
|
||||
remoteItems: readonly { id: string; ts: number; rc?: number }[],
|
||||
localMap: ReadonlyMap<string, { ts: number; rc: number }>
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const item of remoteItems) {
|
||||
const local = localMap.get(item.id);
|
||||
|
||||
if (
|
||||
!local ||
|
||||
item.ts > local.ts ||
|
||||
@@ -111,6 +119,7 @@ export function findMissingIds(
|
||||
missing.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
@@ -127,7 +136,7 @@ export interface MergeResult {
|
||||
*/
|
||||
export async function mergeIncomingMessage(
|
||||
incoming: Message,
|
||||
db: DatabaseService,
|
||||
db: DatabaseService
|
||||
): Promise<MergeResult> {
|
||||
const existing = await db.getMessageById(incoming.id);
|
||||
const existingTs = existing ? getMessageTimestamp(existing) : -1;
|
||||
@@ -140,17 +149,30 @@ export async function mergeIncomingMessage(
|
||||
|
||||
// Persist incoming reactions (deduped by the DB layer)
|
||||
const incomingReactions = incoming.reactions ?? [];
|
||||
|
||||
for (const reaction of incomingReactions) {
|
||||
await db.saveReaction(reaction);
|
||||
}
|
||||
|
||||
const changed = isNewer || incomingReactions.length > 0;
|
||||
|
||||
if (changed) {
|
||||
const reactions = await db.getReactionsForMessage(incoming.id);
|
||||
const baseMessage = isNewer ? incoming : existing;
|
||||
|
||||
if (!baseMessage) {
|
||||
return { message: incoming, changed };
|
||||
}
|
||||
|
||||
return {
|
||||
message: { ...(isNewer ? incoming : existing!), reactions },
|
||||
changed,
|
||||
message: { ...baseMessage, reactions },
|
||||
changed
|
||||
};
|
||||
}
|
||||
return { message: existing!, changed: false };
|
||||
|
||||
if (!existing) {
|
||||
return { message: incoming, changed: false };
|
||||
}
|
||||
|
||||
return { message: existing, changed: false };
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@ export interface MessagesState extends EntityState<Message> {
|
||||
|
||||
export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({
|
||||
selectId: (message) => message.id,
|
||||
sortComparer: (a, b) => a.timestamp - b.timestamp,
|
||||
sortComparer: (messageA, messageB) => messageA.timestamp - messageB.timestamp
|
||||
});
|
||||
|
||||
export const initialState: MessagesState = messagesAdapter.getInitialState({
|
||||
loading: false,
|
||||
syncing: false,
|
||||
error: null,
|
||||
currentRoomId: null,
|
||||
currentRoomId: null
|
||||
});
|
||||
|
||||
export const messagesReducer = createReducer(
|
||||
@@ -37,47 +37,48 @@ export const messagesReducer = createReducer(
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
currentRoomId: roomId,
|
||||
currentRoomId: roomId
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
currentRoomId: roomId,
|
||||
currentRoomId: roomId
|
||||
};
|
||||
}),
|
||||
|
||||
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
|
||||
messagesAdapter.setAll(messages, {
|
||||
...state,
|
||||
loading: false,
|
||||
loading: false
|
||||
})
|
||||
),
|
||||
|
||||
on(MessagesActions.loadMessagesFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Send message
|
||||
on(MessagesActions.sendMessage, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
loading: true
|
||||
})),
|
||||
|
||||
on(MessagesActions.sendMessageSuccess, (state, { message }) =>
|
||||
messagesAdapter.addOne(message, {
|
||||
...state,
|
||||
loading: false,
|
||||
loading: false
|
||||
})
|
||||
),
|
||||
|
||||
on(MessagesActions.sendMessageFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Receive message from peer
|
||||
@@ -90,7 +91,7 @@ export const messagesReducer = createReducer(
|
||||
messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: { content, editedAt },
|
||||
changes: { content, editedAt }
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -101,7 +102,7 @@ export const messagesReducer = createReducer(
|
||||
messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: { isDeleted: true, content: '[Message deleted]' },
|
||||
changes: { isDeleted: true, content: '[Message deleted]' }
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -110,20 +111,23 @@ export const messagesReducer = createReducer(
|
||||
// Add reaction
|
||||
on(MessagesActions.addReactionSuccess, (state, { reaction }) => {
|
||||
const message = state.entities[reaction.messageId];
|
||||
if (!message) return state;
|
||||
|
||||
if (!message)
|
||||
return state;
|
||||
|
||||
const existingReaction = message.reactions.find(
|
||||
(existing) => existing.emoji === reaction.emoji && existing.userId === reaction.userId
|
||||
);
|
||||
|
||||
if (existingReaction) return state;
|
||||
if (existingReaction)
|
||||
return state;
|
||||
|
||||
return messagesAdapter.updateOne(
|
||||
{
|
||||
id: reaction.messageId,
|
||||
changes: {
|
||||
reactions: [...message.reactions, reaction],
|
||||
},
|
||||
reactions: [...message.reactions, reaction]
|
||||
}
|
||||
},
|
||||
state
|
||||
);
|
||||
@@ -132,7 +136,9 @@ export const messagesReducer = createReducer(
|
||||
// Remove reaction
|
||||
on(MessagesActions.removeReactionSuccess, (state, { messageId, emoji, oderId }) => {
|
||||
const message = state.entities[messageId];
|
||||
if (!message) return state;
|
||||
|
||||
if (!message)
|
||||
return state;
|
||||
|
||||
return messagesAdapter.updateOne(
|
||||
{
|
||||
@@ -140,8 +146,8 @@ export const messagesReducer = createReducer(
|
||||
changes: {
|
||||
reactions: message.reactions.filter(
|
||||
(existingReaction) => !(existingReaction.emoji === emoji && existingReaction.userId === oderId)
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
state
|
||||
);
|
||||
@@ -150,32 +156,43 @@ export const messagesReducer = createReducer(
|
||||
// Sync lifecycle
|
||||
on(MessagesActions.startSync, (state) => ({
|
||||
...state,
|
||||
syncing: true,
|
||||
syncing: true
|
||||
})),
|
||||
|
||||
on(MessagesActions.syncComplete, (state) => ({
|
||||
...state,
|
||||
syncing: false,
|
||||
syncing: false
|
||||
})),
|
||||
|
||||
// Sync messages from peer (merge reactions to avoid losing local-only reactions)
|
||||
on(MessagesActions.syncMessages, (state, { messages }) => {
|
||||
const merged = messages.map(message => {
|
||||
const existing = state.entities[message.id];
|
||||
|
||||
if (existing?.reactions?.length) {
|
||||
const combined = [...(message.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);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...message, reactions: combined };
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
|
||||
return messagesAdapter.upsertMany(merged, {
|
||||
...state,
|
||||
syncing: false,
|
||||
syncing: false
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -183,7 +200,7 @@ export const messagesReducer = createReducer(
|
||||
on(MessagesActions.clearMessages, (state) =>
|
||||
messagesAdapter.removeAll({
|
||||
...state,
|
||||
currentRoomId: null,
|
||||
currentRoomId: null
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -55,7 +55,9 @@ export const selectChannelMessages = (channelId: string) =>
|
||||
selectAllMessages,
|
||||
selectCurrentRoomId,
|
||||
(messages, roomId) => {
|
||||
if (!roomId) return [];
|
||||
if (!roomId)
|
||||
return [];
|
||||
|
||||
return messages.filter(
|
||||
(message) => message.roomId === roomId && (message.channelId || 'general') === channelId
|
||||
);
|
||||
|
||||
@@ -57,6 +57,6 @@ export const RoomsActions = createActionGroup({
|
||||
'Rename Channel': props<{ channelId: string; name: string }>(),
|
||||
|
||||
'Clear Search Results': emptyProps(),
|
||||
'Set Connecting': props<{ isConnecting: boolean }>(),
|
||||
},
|
||||
'Set Connecting': props<{ isConnecting: boolean }>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 { Router } from '@angular/router';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
@@ -11,7 +14,7 @@ import {
|
||||
tap,
|
||||
debounceTime,
|
||||
switchMap,
|
||||
filter,
|
||||
filter
|
||||
} from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
@@ -28,7 +31,7 @@ import { NotificationAudioService, AppSound } from '../../core/services/notifica
|
||||
/** Build a minimal User object from signaling payload. */
|
||||
function buildSignalingUser(
|
||||
data: { oderId: string; displayName: string },
|
||||
extras: Record<string, unknown> = {},
|
||||
extras: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
oderId: data.oderId,
|
||||
@@ -39,14 +42,14 @@ function buildSignalingUser(
|
||||
isOnline: true,
|
||||
role: 'member' as const,
|
||||
joinedAt: Date.now(),
|
||||
...extras,
|
||||
...extras
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns true when the message's server ID does not match the viewed server. */
|
||||
function isWrongServer(
|
||||
msgServerId: string | undefined,
|
||||
viewedServerId: string | undefined,
|
||||
viewedServerId: string | undefined
|
||||
): boolean {
|
||||
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
||||
}
|
||||
@@ -68,10 +71,10 @@ export class RoomsEffects {
|
||||
switchMap(() =>
|
||||
from(this.db.getAllRooms()).pipe(
|
||||
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. */
|
||||
@@ -82,10 +85,10 @@ export class RoomsEffects {
|
||||
switchMap(({ query }) =>
|
||||
this.serverDirectory.searchServers(query).pipe(
|
||||
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. */
|
||||
@@ -108,7 +111,7 @@ export class RoomsEffects {
|
||||
password,
|
||||
createdAt: Date.now(),
|
||||
userCount: 1,
|
||||
maxUsers: 50,
|
||||
maxUsers: 50
|
||||
};
|
||||
|
||||
// Save to local DB
|
||||
@@ -126,14 +129,14 @@ export class RoomsEffects {
|
||||
isPrivate: room.isPrivate,
|
||||
userCount: 1,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
tags: [],
|
||||
tags: []
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
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. */
|
||||
@@ -164,8 +167,9 @@ export class RoomsEffects {
|
||||
password,
|
||||
createdAt: Date.now(),
|
||||
userCount: 1,
|
||||
maxUsers: 50,
|
||||
maxUsers: 50
|
||||
};
|
||||
|
||||
// Save to local DB for future reference
|
||||
this.db.saveRoom(newRoom);
|
||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||
@@ -184,20 +188,22 @@ export class RoomsEffects {
|
||||
password,
|
||||
createdAt: serverData.createdAt || Date.now(),
|
||||
userCount: serverData.userCount,
|
||||
maxUsers: serverData.maxUsers,
|
||||
maxUsers: serverData.maxUsers
|
||||
};
|
||||
|
||||
this.db.saveRoom(newRoom);
|
||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -224,14 +230,14 @@ export class RoomsEffects {
|
||||
this.webrtc.joinRoom(room.id, oderId);
|
||||
}
|
||||
},
|
||||
error: () => {},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate(['/room', room.id]);
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ dispatch: false },
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** 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]);
|
||||
return of(RoomsActions.viewServerSuccess({ room }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Reloads messages and users when the viewed server changes. */
|
||||
@@ -260,17 +266,17 @@ export class RoomsEffects {
|
||||
mergeMap(({ room }) => [
|
||||
UsersActions.clearUsers(),
|
||||
MessagesActions.loadMessages({ roomId: room.id }),
|
||||
UsersActions.loadBans(),
|
||||
]),
|
||||
),
|
||||
UsersActions.loadBans()
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
/** Handles leave-room dispatches (navigation only, peers stay connected). */
|
||||
leaveRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.leaveRoom),
|
||||
map(() => RoomsActions.leaveRoomSuccess()),
|
||||
),
|
||||
map(() => RoomsActions.leaveRoomSuccess())
|
||||
)
|
||||
);
|
||||
|
||||
/** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */
|
||||
@@ -279,15 +285,15 @@ export class RoomsEffects {
|
||||
ofType(RoomsActions.deleteRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
filter(
|
||||
([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id,
|
||||
([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id
|
||||
),
|
||||
switchMap(([{ roomId }]) => {
|
||||
this.db.deleteRoom(roomId);
|
||||
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId });
|
||||
this.webrtc.disconnectAll();
|
||||
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** 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);
|
||||
|
||||
return of(RoomsActions.forgetRoomSuccess({ roomId }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** 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') {
|
||||
return of(
|
||||
RoomsActions.updateRoomSettingsFailure({
|
||||
error: 'Permission denied',
|
||||
}),
|
||||
error: 'Permission denied'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,7 +338,7 @@ export class RoomsEffects {
|
||||
topic: settings.topic ?? currentRoom.topic,
|
||||
isPrivate: settings.isPrivate ?? currentRoom.isPrivate,
|
||||
password: settings.password ?? currentRoom.password,
|
||||
maxUsers: settings.maxUsers ?? currentRoom.maxUsers,
|
||||
maxUsers: settings.maxUsers ?? currentRoom.maxUsers
|
||||
};
|
||||
|
||||
// Update local DB
|
||||
@@ -341,13 +347,13 @@ export class RoomsEffects {
|
||||
// Broadcast to all peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'room-settings-update',
|
||||
settings: updatedSettings,
|
||||
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. */
|
||||
@@ -360,9 +366,9 @@ export class RoomsEffects {
|
||||
if (currentRoom && currentRoom.id === roomId) {
|
||||
this.db.updateRoom(roomId, changes);
|
||||
}
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ dispatch: false },
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Updates room permission grants (host-only) and broadcasts to peers. */
|
||||
@@ -375,21 +381,22 @@ export class RoomsEffects {
|
||||
!!currentUser &&
|
||||
!!currentRoom &&
|
||||
currentRoom.id === roomId &&
|
||||
currentRoom.hostId === currentUser.id,
|
||||
currentRoom.hostId === currentUser.id
|
||||
),
|
||||
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
|
||||
const updated: Partial<Room> = {
|
||||
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions,
|
||||
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions
|
||||
};
|
||||
|
||||
this.db.updateRoom(roomId, updated);
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'room-permissions-update',
|
||||
permissions: updated.permissions,
|
||||
permissions: updated.permissions
|
||||
} as any);
|
||||
return of(RoomsActions.updateRoom({ roomId, changes: updated }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Updates the server icon (permission-enforced) and broadcasts to peers. */
|
||||
@@ -408,23 +415,25 @@ export class RoomsEffects {
|
||||
const canByRole =
|
||||
(role === 'admin' && perms.adminsManageIcon) ||
|
||||
(role === 'moderator' && perms.moderatorsManageIcon);
|
||||
|
||||
if (!isOwner && !canByRole) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
||||
}
|
||||
|
||||
const iconUpdatedAt = Date.now();
|
||||
const changes: Partial<Room> = { icon, iconUpdatedAt };
|
||||
|
||||
this.db.updateRoom(roomId, changes);
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'server-icon-update',
|
||||
roomId,
|
||||
icon,
|
||||
iconUpdatedAt,
|
||||
iconUpdatedAt
|
||||
} as any);
|
||||
return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Persists newly created room to the local database. */
|
||||
@@ -434,17 +443,17 @@ export class RoomsEffects {
|
||||
ofType(RoomsActions.createRoomSuccess),
|
||||
tap(({ room }) => {
|
||||
this.db.saveRoom(room);
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ dispatch: false },
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Set the creator's role to 'host' after creating a room. */
|
||||
setHostRoleOnCreate$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
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. */
|
||||
@@ -453,8 +462,8 @@ export class RoomsEffects {
|
||||
ofType(RoomsActions.joinRoomSuccess),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
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. */
|
||||
@@ -465,17 +474,17 @@ export class RoomsEffects {
|
||||
MessagesActions.loadMessages({ roomId: room.id }),
|
||||
// Don't load users from database - they come from signaling server
|
||||
// UsersActions.loadRoomUsers({ roomId: room.id }),
|
||||
UsersActions.loadBans(),
|
||||
]),
|
||||
),
|
||||
UsersActions.loadBans()
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
/** Clears messages and users from the store when leaving a room. */
|
||||
onLeaveRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.leaveRoomSuccess),
|
||||
mergeMap(() => [MessagesActions.clearMessages(), UsersActions.clearUsers()]),
|
||||
),
|
||||
mergeMap(() => [MessagesActions.clearMessages(), UsersActions.clearUsers()])
|
||||
)
|
||||
);
|
||||
|
||||
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
|
||||
@@ -488,26 +497,35 @@ export class RoomsEffects {
|
||||
|
||||
switch (message.type) {
|
||||
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 }[])
|
||||
.filter((u) => u.oderId !== myId)
|
||||
.map((u) => UsersActions.userJoined({ user: buildSignalingUser(u) }));
|
||||
|
||||
return [UsersActions.clearUsers(), ...joinActions];
|
||||
}
|
||||
|
||||
case 'user_joined': {
|
||||
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId)
|
||||
return EMPTY;
|
||||
|
||||
return [UsersActions.userJoined({ user: buildSignalingUser(message) })];
|
||||
}
|
||||
|
||||
case 'user_left': {
|
||||
if (isWrongServer(message.serverId, viewedServerId)) return EMPTY;
|
||||
if (isWrongServer(message.serverId, viewedServerId))
|
||||
return EMPTY;
|
||||
|
||||
return [UsersActions.userLeft({ userId: message.oderId })];
|
||||
}
|
||||
|
||||
default:
|
||||
return EMPTY;
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Processes incoming P2P room and icon-sync events. */
|
||||
@@ -517,6 +535,7 @@ export class RoomsEffects {
|
||||
filter(([, room]) => !!room),
|
||||
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
|
||||
const room = currentRoom as Room;
|
||||
|
||||
switch (event.type) {
|
||||
case 'voice-state':
|
||||
return this.handleVoiceOrScreenState(event, allUsers, 'voice');
|
||||
@@ -534,22 +553,27 @@ export class RoomsEffects {
|
||||
default:
|
||||
return EMPTY;
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
private handleVoiceOrScreenState(event: any, allUsers: any[], kind: 'voice' | 'screen') {
|
||||
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);
|
||||
|
||||
if (kind === 'voice') {
|
||||
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.
|
||||
const weAreInVoice = this.webrtc.isVoiceConnected();
|
||||
|
||||
if (weAreInVoice) {
|
||||
const existingUser = allUsers.find((u) => u.id === userId || u.oderId === userId) as any;
|
||||
const wasConnected = existingUser?.voiceState?.isConnected ?? false;
|
||||
@@ -576,41 +600,48 @@ export class RoomsEffects {
|
||||
isMutedByAdmin: vs.isMutedByAdmin,
|
||||
volume: vs.volume,
|
||||
roomId: vs.roomId,
|
||||
serverId: vs.serverId,
|
||||
},
|
||||
},
|
||||
),
|
||||
}),
|
||||
serverId: vs.serverId
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
||||
}
|
||||
|
||||
// screen-state
|
||||
const isSharing = event.isScreenSharing as boolean | undefined;
|
||||
if (isSharing === undefined) return EMPTY;
|
||||
|
||||
if (isSharing === undefined)
|
||||
return EMPTY;
|
||||
|
||||
if (!userExists) {
|
||||
return of(
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||
{ screenShareState: { isSharing } },
|
||||
),
|
||||
}),
|
||||
{ screenShareState: { isSharing } }
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return of(
|
||||
UsersActions.updateScreenShareState({
|
||||
userId,
|
||||
screenShareState: { isSharing },
|
||||
}),
|
||||
screenShareState: { isSharing }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleRoomSettingsUpdate(event: any, room: Room) {
|
||||
const settings: RoomSettings | undefined = event.settings;
|
||||
if (!settings) return EMPTY;
|
||||
|
||||
if (!settings)
|
||||
return EMPTY;
|
||||
|
||||
this.db.updateRoom(room.id, settings);
|
||||
return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> }));
|
||||
}
|
||||
@@ -618,12 +649,14 @@ export class RoomsEffects {
|
||||
private handleIconSummary(event: any, room: Room) {
|
||||
const remoteUpdated = event.iconUpdatedAt || 0;
|
||||
const localUpdated = room.iconUpdatedAt || 0;
|
||||
|
||||
if (remoteUpdated > localUpdated && event.fromPeerId) {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'server-icon-request',
|
||||
roomId: room.id,
|
||||
roomId: room.id
|
||||
} as any);
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -633,34 +666,42 @@ export class RoomsEffects {
|
||||
type: 'server-icon-full',
|
||||
roomId: room.id,
|
||||
icon: room.icon,
|
||||
iconUpdatedAt: room.iconUpdatedAt || 0,
|
||||
iconUpdatedAt: room.iconUpdatedAt || 0
|
||||
} as any);
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
private handleIconData(event: any, room: Room) {
|
||||
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(
|
||||
map((users) => users.find((u) => u.id === senderId)),
|
||||
mergeMap((sender) => {
|
||||
if (!sender) return EMPTY;
|
||||
if (!sender)
|
||||
return EMPTY;
|
||||
|
||||
const perms = room.permissions || {};
|
||||
const isOwner = room.hostId === sender.id;
|
||||
const canByRole =
|
||||
(sender.role === 'admin' && perms.adminsManageIcon) ||
|
||||
(sender.role === 'moderator' && perms.moderatorsManageIcon);
|
||||
if (!isOwner && !canByRole) return EMPTY;
|
||||
|
||||
if (!isOwner && !canByRole)
|
||||
return EMPTY;
|
||||
|
||||
const updates: Partial<Room> = {
|
||||
icon: event.icon,
|
||||
iconUpdatedAt: event.iconUpdatedAt || Date.now(),
|
||||
iconUpdatedAt: event.iconUpdatedAt || Date.now()
|
||||
};
|
||||
|
||||
this.db.updateRoom(room.id, updates);
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,15 +711,18 @@ export class RoomsEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
tap(([peerId, room]) => {
|
||||
if (!room) return;
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
const iconUpdatedAt = room.iconUpdatedAt || 0;
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'server-icon-summary',
|
||||
roomId: room.id,
|
||||
iconUpdatedAt,
|
||||
iconUpdatedAt
|
||||
} as any);
|
||||
}),
|
||||
})
|
||||
),
|
||||
{ dispatch: false },
|
||||
{ dispatch: false }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,27 +8,32 @@ export function defaultChannels(): Channel[] {
|
||||
{ id: 'general', name: 'general', type: 'text', position: 0 },
|
||||
{ id: 'random', name: 'random', type: 'text', position: 1 },
|
||||
{ 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 */
|
||||
function deduplicateRooms(rooms: Room[]): Room[] {
|
||||
const seen = new Map<string, Room>();
|
||||
|
||||
for (const room of rooms) {
|
||||
seen.set(room.id, room);
|
||||
}
|
||||
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Upsert a room into a saved-rooms list (add or replace by id) */
|
||||
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
|
||||
|
||||
if (idx >= 0) {
|
||||
const updated = [...savedRooms];
|
||||
|
||||
updated[idx] = room;
|
||||
return updated;
|
||||
}
|
||||
|
||||
return [...savedRooms, room];
|
||||
}
|
||||
|
||||
@@ -66,7 +71,7 @@ export const initialState: RoomsState = {
|
||||
isConnected: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
activeChannelId: 'general',
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
|
||||
export const roomsReducer = createReducer(
|
||||
@@ -76,94 +81,96 @@ export const roomsReducer = createReducer(
|
||||
on(RoomsActions.loadRooms, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
|
||||
...state,
|
||||
savedRooms: deduplicateRooms(rooms),
|
||||
loading: false,
|
||||
loading: false
|
||||
})),
|
||||
|
||||
on(RoomsActions.loadRoomsFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Search servers
|
||||
on(RoomsActions.searchServers, (state) => ({
|
||||
...state,
|
||||
isSearching: true,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.searchServersSuccess, (state, { servers }) => ({
|
||||
...state,
|
||||
searchResults: servers,
|
||||
isSearching: false,
|
||||
isSearching: false
|
||||
})),
|
||||
|
||||
on(RoomsActions.searchServersFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isSearching: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Create room
|
||||
on(RoomsActions.createRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.createRoomSuccess, (state, { room }) => {
|
||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
activeChannelId: 'general',
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.createRoomFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isConnecting: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Join room
|
||||
on(RoomsActions.joinRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
|
||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
activeChannelId: 'general',
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.joinRoomFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isConnecting: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Leave room
|
||||
on(RoomsActions.leaveRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
isConnecting: true
|
||||
})),
|
||||
|
||||
on(RoomsActions.leaveRoomSuccess, (state) => ({
|
||||
@@ -171,32 +178,33 @@ export const roomsReducer = createReducer(
|
||||
currentRoom: null,
|
||||
roomSettings: null,
|
||||
isConnecting: false,
|
||||
isConnected: false,
|
||||
isConnected: false
|
||||
})),
|
||||
|
||||
// View server – just switch the viewed room, stay connected
|
||||
on(RoomsActions.viewServer, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
activeChannelId: 'general',
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
}),
|
||||
|
||||
// Update room settings
|
||||
on(RoomsActions.updateRoomSettings, (state) => ({
|
||||
...state,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.updateRoomSettingsSuccess, (state, { settings }) => ({
|
||||
@@ -204,41 +212,41 @@ export const roomsReducer = createReducer(
|
||||
roomSettings: settings,
|
||||
currentRoom: state.currentRoom
|
||||
? {
|
||||
...state.currentRoom,
|
||||
name: settings.name,
|
||||
description: settings.description,
|
||||
topic: settings.topic,
|
||||
isPrivate: settings.isPrivate,
|
||||
password: settings.password,
|
||||
maxUsers: settings.maxUsers,
|
||||
}
|
||||
: null,
|
||||
...state.currentRoom,
|
||||
name: settings.name,
|
||||
description: settings.description,
|
||||
topic: settings.topic,
|
||||
isPrivate: settings.isPrivate,
|
||||
password: settings.password,
|
||||
maxUsers: settings.maxUsers
|
||||
}
|
||||
: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({
|
||||
...state,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Delete room
|
||||
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
||||
...state,
|
||||
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)
|
||||
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
||||
...state,
|
||||
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
|
||||
on(RoomsActions.setCurrentRoom, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: room,
|
||||
isConnected: true,
|
||||
isConnected: true
|
||||
})),
|
||||
|
||||
// Clear current room
|
||||
@@ -246,85 +254,98 @@ export const roomsReducer = createReducer(
|
||||
...state,
|
||||
currentRoom: null,
|
||||
roomSettings: null,
|
||||
isConnected: false,
|
||||
isConnected: false
|
||||
})),
|
||||
|
||||
// Update room
|
||||
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
|
||||
if (state.currentRoom?.id !== roomId) return state;
|
||||
if (state.currentRoom?.id !== roomId)
|
||||
return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: { ...state.currentRoom, ...changes },
|
||||
currentRoom: { ...state.currentRoom, ...changes }
|
||||
};
|
||||
}),
|
||||
|
||||
// Update server icon success
|
||||
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
|
||||
if (state.currentRoom?.id !== roomId) return state;
|
||||
if (state.currentRoom?.id !== roomId)
|
||||
return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: { ...state.currentRoom, icon, iconUpdatedAt },
|
||||
currentRoom: { ...state.currentRoom, icon, iconUpdatedAt }
|
||||
};
|
||||
}),
|
||||
|
||||
// Receive room update
|
||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null,
|
||||
currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null
|
||||
})),
|
||||
|
||||
// Clear search results
|
||||
on(RoomsActions.clearSearchResults, (state) => ({
|
||||
...state,
|
||||
searchResults: [],
|
||||
searchResults: []
|
||||
})),
|
||||
|
||||
// Set connecting
|
||||
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
|
||||
...state,
|
||||
isConnecting,
|
||||
isConnecting
|
||||
})),
|
||||
|
||||
// Channel management
|
||||
on(RoomsActions.selectChannel, (state, { channelId }) => ({
|
||||
...state,
|
||||
activeChannelId: channelId,
|
||||
activeChannelId: channelId
|
||||
})),
|
||||
|
||||
on(RoomsActions.addChannel, (state, { channel }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = [...existing, channel];
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: updatedRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: 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 }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel);
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: updatedRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -91,11 +91,15 @@ export const selectCurrentRoomChannels = createSelector(
|
||||
/** Selects only text channels, sorted by position. */
|
||||
export const selectTextChannels = createSelector(
|
||||
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. */
|
||||
export const selectVoiceChannels = createSelector(
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -43,6 +43,6 @@ export const UsersActions = createActionGroup({
|
||||
'Update Host': props<{ userId: string }>(),
|
||||
|
||||
'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> }>()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Users store effects (load, kick, ban, host election, profile persistence).
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -32,6 +33,7 @@ export class UsersEffects {
|
||||
if (user) {
|
||||
return UsersActions.loadCurrentUserSuccess({ user });
|
||||
}
|
||||
|
||||
return UsersActions.loadCurrentUserFailure({ error: 'No current user' });
|
||||
}),
|
||||
catchError((error) =>
|
||||
@@ -63,27 +65,30 @@ export class UsersEffects {
|
||||
ofType(UsersActions.kickUser),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ userId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) return EMPTY;
|
||||
if (!currentUser || !currentRoom)
|
||||
return EMPTY;
|
||||
|
||||
const canKick =
|
||||
currentUser.role === 'host' ||
|
||||
currentUser.role === 'admin' ||
|
||||
currentUser.role === 'moderator';
|
||||
if (!canKick) return EMPTY;
|
||||
|
||||
if (!canKick)
|
||||
return EMPTY;
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: userId,
|
||||
roomId: currentRoom.id,
|
||||
kickedBy: currentUser.id,
|
||||
kickedBy: currentUser.id
|
||||
});
|
||||
|
||||
return of(UsersActions.kickUserSuccess({ userId }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */
|
||||
@@ -92,13 +97,16 @@ export class UsersEffects {
|
||||
ofType(UsersActions.banUser),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) return EMPTY;
|
||||
if (!currentUser || !currentRoom)
|
||||
return EMPTY;
|
||||
|
||||
const canBan = currentUser.role === 'host' || currentUser.role === 'admin';
|
||||
if (!canBan) return EMPTY;
|
||||
|
||||
if (!canBan)
|
||||
return EMPTY;
|
||||
|
||||
const ban: BanEntry = {
|
||||
oderId: uuidv4(),
|
||||
@@ -107,7 +115,7 @@ export class UsersEffects {
|
||||
bannedBy: currentUser.id,
|
||||
reason,
|
||||
expiresAt,
|
||||
timestamp: Date.now(),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.db.saveBan(ban);
|
||||
@@ -116,12 +124,12 @@ export class UsersEffects {
|
||||
targetUserId: userId,
|
||||
roomId: currentRoom.id,
|
||||
bannedBy: currentUser.id,
|
||||
reason,
|
||||
reason
|
||||
});
|
||||
|
||||
return of(UsersActions.banUserSuccess({ userId, ban }));
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Removes a ban entry from the local database. */
|
||||
@@ -146,6 +154,7 @@ export class UsersEffects {
|
||||
if (!currentRoom) {
|
||||
return of(UsersActions.loadBansSuccess({ bans: [] }));
|
||||
}
|
||||
|
||||
return from(this.db.getBansForRoom(currentRoom.id)).pipe(
|
||||
map((bans) => UsersActions.loadBansSuccess({ bans })),
|
||||
catchError(() => of(UsersActions.loadBansSuccess({ bans: [] })))
|
||||
@@ -165,8 +174,8 @@ export class UsersEffects {
|
||||
mergeMap(([{ userId }, hostId, currentUserId]) =>
|
||||
userId === hostId && currentUserId
|
||||
? of(UsersActions.updateHost({ userId: currentUserId }))
|
||||
: EMPTY,
|
||||
),
|
||||
: EMPTY
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface UsersState extends EntityState<User> {
|
||||
|
||||
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
|
||||
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({
|
||||
@@ -27,7 +27,7 @@ export const initialState: UsersState = usersAdapter.getInitialState({
|
||||
hostId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
bans: [],
|
||||
bans: []
|
||||
});
|
||||
|
||||
export const usersReducer = createReducer(
|
||||
@@ -37,38 +37,40 @@ export const usersReducer = createReducer(
|
||||
on(UsersActions.loadCurrentUser, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(UsersActions.loadCurrentUserSuccess, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, {
|
||||
...state,
|
||||
currentUserId: user.id,
|
||||
loading: false,
|
||||
loading: false
|
||||
})
|
||||
),
|
||||
|
||||
on(UsersActions.loadCurrentUserFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// Set current user
|
||||
on(UsersActions.setCurrentUser, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, {
|
||||
...state,
|
||||
currentUserId: user.id,
|
||||
currentUserId: user.id
|
||||
})
|
||||
),
|
||||
|
||||
// Update current user
|
||||
on(UsersActions.updateCurrentUser, (state, { updates }) => {
|
||||
if (!state.currentUserId) return state;
|
||||
if (!state.currentUserId)
|
||||
return state;
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: state.currentUserId,
|
||||
changes: updates,
|
||||
changes: updates
|
||||
},
|
||||
state
|
||||
);
|
||||
@@ -78,20 +80,20 @@ export const usersReducer = createReducer(
|
||||
on(UsersActions.loadRoomUsers, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(UsersActions.loadRoomUsersSuccess, (state, { users }) =>
|
||||
usersAdapter.upsertMany(users, {
|
||||
...state,
|
||||
loading: false,
|
||||
loading: false
|
||||
})
|
||||
),
|
||||
|
||||
on(UsersActions.loadRoomUsersFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
error
|
||||
})),
|
||||
|
||||
// User joined
|
||||
@@ -109,7 +111,7 @@ export const usersReducer = createReducer(
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: updates,
|
||||
changes: updates
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -120,7 +122,7 @@ export const usersReducer = createReducer(
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: { role },
|
||||
changes: { role }
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -134,22 +136,23 @@ export const usersReducer = createReducer(
|
||||
// Ban user
|
||||
on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
|
||||
const newState = usersAdapter.removeOne(userId, state);
|
||||
|
||||
return {
|
||||
...newState,
|
||||
bans: [...state.bans, ban],
|
||||
bans: [...state.bans, ban]
|
||||
};
|
||||
}),
|
||||
|
||||
// Unban user
|
||||
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
||||
...state,
|
||||
bans: state.bans.filter((ban) => ban.oderId !== oderId),
|
||||
bans: state.bans.filter((ban) => ban.oderId !== oderId)
|
||||
})),
|
||||
|
||||
// Load bans
|
||||
on(UsersActions.loadBansSuccess, (state, { bans }) => ({
|
||||
...state,
|
||||
bans,
|
||||
bans
|
||||
})),
|
||||
|
||||
// Admin mute
|
||||
@@ -164,9 +167,9 @@ export const usersReducer = createReducer(
|
||||
isMuted: true,
|
||||
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
|
||||
isSpeaking: false,
|
||||
isMutedByAdmin: true,
|
||||
},
|
||||
},
|
||||
isMutedByAdmin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -184,9 +187,9 @@ export const usersReducer = createReducer(
|
||||
isMuted: state.entities[userId]?.voiceState?.isMuted ?? false,
|
||||
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
|
||||
isSpeaking: state.entities[userId]?.voiceState?.isSpeaking ?? false,
|
||||
isMutedByAdmin: false,
|
||||
},
|
||||
},
|
||||
isMutedByAdmin: false
|
||||
}
|
||||
}
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -198,8 +201,9 @@ export const usersReducer = createReducer(
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
@@ -213,9 +217,9 @@ export const usersReducer = createReducer(
|
||||
volume: voiceState.volume ?? prev.volume,
|
||||
// Use explicit undefined check - if undefined is passed, clear the value
|
||||
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
|
||||
);
|
||||
@@ -224,8 +228,9 @@ export const usersReducer = createReducer(
|
||||
// Update screen share state
|
||||
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
|
||||
const prev = state.entities[userId]?.screenShareState || {
|
||||
isSharing: false,
|
||||
isSharing: false
|
||||
};
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
@@ -234,9 +239,9 @@ export const usersReducer = createReducer(
|
||||
isSharing: screenShareState.isSharing ?? prev.isSharing,
|
||||
streamId: screenShareState.streamId ?? prev.streamId,
|
||||
sourceId: screenShareState.sourceId ?? prev.sourceId,
|
||||
sourceName: screenShareState.sourceName ?? prev.sourceName,
|
||||
},
|
||||
},
|
||||
sourceName: screenShareState.sourceName ?? prev.sourceName
|
||||
}
|
||||
}
|
||||
},
|
||||
state
|
||||
);
|
||||
@@ -250,9 +255,10 @@ export const usersReducer = createReducer(
|
||||
// Clear users
|
||||
on(UsersActions.clearUsers, (state) => {
|
||||
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
|
||||
|
||||
return usersAdapter.removeMany(idsToRemove, {
|
||||
...state,
|
||||
hostId: null,
|
||||
hostId: null
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -260,11 +266,12 @@ export const usersReducer = createReducer(
|
||||
on(UsersActions.updateHost, (state, { userId }) => {
|
||||
// Update the old host's role to member
|
||||
let newState = state;
|
||||
|
||||
if (state.hostId && state.hostId !== userId) {
|
||||
newState = usersAdapter.updateOne(
|
||||
{
|
||||
id: state.hostId,
|
||||
changes: { role: 'member' },
|
||||
changes: { role: 'member' }
|
||||
},
|
||||
state
|
||||
);
|
||||
@@ -274,11 +281,11 @@ export const usersReducer = createReducer(
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: { role: 'host' },
|
||||
changes: { role: 'host' }
|
||||
},
|
||||
{
|
||||
...newState,
|
||||
hostId: userId,
|
||||
hostId: userId
|
||||
}
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
serverUrl: 'https://your-server.com/api',
|
||||
signalingUrl: 'wss://your-server.com/signaling',
|
||||
signalingUrl: 'wss://your-server.com/signaling'
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
serverUrl: 'http://localhost:3000/api',
|
||||
signalingUrl: 'ws://localhost:3001',
|
||||
signalingUrl: 'ws://localhost:3001'
|
||||
};
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@@ -3,12 +3,18 @@ import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mermaid: typeof mermaid;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose mermaid globally for ngx-remark's MermaidComponent
|
||||
(window as any)['mermaid'] = mermaid;
|
||||
window.mermaid = mermaid;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
theme: 'dark',
|
||||
theme: 'dark'
|
||||
});
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"rootDir": "./src",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user