From ad0e28bf84bc90785ae532b5945cdbeb5fc57cba Mon Sep 17 00:00:00 2001 From: Myx Date: Tue, 3 Mar 2026 22:56:12 +0100 Subject: [PATCH] Add eslint --- eslint.config.js | 218 ++++++++++++++++ package.json | 9 +- server/dist/db.d.ts | 48 ++-- server/dist/index.d.ts | 2 +- server/src/db.ts | 97 ++++++-- server/src/index.ts | 119 ++++++--- src/app/app.config.ts | 8 +- src/app/app.routes.ts | 14 +- src/app/app.ts | 12 +- src/app/core/services/attachment.service.ts | 172 +++++++++---- src/app/core/services/auth.service.ts | 10 +- .../core/services/browser-database.service.ts | 75 ++++-- src/app/core/services/database.service.ts | 1 + .../services/electron-database.service.ts | 4 +- .../core/services/external-link.service.ts | 21 +- .../services/notification-audio.service.ts | 24 +- .../core/services/server-directory.service.ts | 123 +++++---- src/app/core/services/time-sync.service.ts | 12 +- .../core/services/voice-activity.service.ts | 49 +++- .../core/services/voice-leveling.service.ts | 40 ++- .../core/services/voice-session.service.ts | 15 +- src/app/core/services/webrtc.service.ts | 72 ++++-- src/app/core/services/webrtc/media.manager.ts | 64 +++-- .../webrtc/noise-reduction.manager.ts | 13 +- .../webrtc/peer-connection.manager.ts | 143 ++++++++--- .../services/webrtc/screen-share.manager.ts | 60 ++++- .../core/services/webrtc/signaling.manager.ts | 34 ++- .../services/webrtc/voice-leveling.manager.ts | 44 +++- src/app/core/services/webrtc/webrtc-logger.ts | 21 +- .../core/services/webrtc/webrtc.constants.ts | 4 +- .../admin-panel/admin-panel.component.html | 20 +- .../admin-panel/admin-panel.component.ts | 43 ++-- .../features/auth/login/login.component.html | 19 +- .../features/auth/login/login.component.ts | 14 +- .../auth/register/register.component.html | 22 +- .../auth/register/register.component.ts | 14 +- .../auth/user-bar/user-bar.component.html | 4 +- .../auth/user-bar/user-bar.component.ts | 3 +- .../chat-messages.component.html | 1 + .../chat-messages/chat-messages.component.ts | 171 ++++++++++--- .../services/chat-markdown.service.ts | 18 +- .../typing-indicator.component.ts | 17 +- .../chat/user-list/user-list.component.html | 18 +- .../chat/user-list/user-list.component.ts | 18 +- .../room/chat-room/chat-room.component.ts | 17 +- .../rooms-side-panel.component.html | 1 + .../rooms-side-panel.component.ts | 141 ++++++++--- .../server-search.component.html | 38 ++- .../server-search/server-search.component.ts | 30 ++- .../servers/servers-rail.component.ts | 23 +- .../bans-settings/bans-settings.component.ts | 8 +- .../members-settings.component.ts | 14 +- .../network-settings.component.ts | 26 +- .../permissions-settings.component.ts | 24 +- .../server-settings.component.html | 16 +- .../server-settings.component.ts | 32 ++- .../settings-modal.component.html | 13 + .../settings-modal.component.ts | 53 ++-- .../voice-settings.component.html | 42 +++- .../voice-settings.component.ts | 81 ++++-- .../features/settings/settings.component.ts | 29 ++- .../features/shell/title-bar.component.html | 24 +- src/app/features/shell/title-bar.component.ts | 17 +- .../floating-voice-controls.component.html | 5 + .../floating-voice-controls.component.ts | 31 ++- .../screen-share-viewer.component.html | 3 + .../screen-share-viewer.component.ts | 52 ++-- .../services/voice-playback.service.ts | 35 ++- .../voice-controls.component.html | 11 +- .../voice-controls.component.ts | 118 ++++++--- .../confirm-dialog.component.ts | 24 +- .../context-menu/context-menu.component.ts | 20 +- .../user-avatar/user-avatar.component.ts | 5 +- src/app/store/index.ts | 8 +- .../messages/messages-incoming.handlers.ts | 170 +++++++------ .../store/messages/messages-sync.effects.ts | 67 ++--- src/app/store/messages/messages.actions.ts | 4 +- src/app/store/messages/messages.effects.ts | 80 +++--- src/app/store/messages/messages.helpers.ts | 50 ++-- src/app/store/messages/messages.reducer.ts | 63 +++-- src/app/store/messages/messages.selectors.ts | 4 +- src/app/store/rooms/rooms.actions.ts | 4 +- src/app/store/rooms/rooms.effects.ts | 234 +++++++++++------- src/app/store/rooms/rooms.reducer.ts | 115 +++++---- src/app/store/rooms/rooms.selectors.ts | 8 +- src/app/store/users/users.actions.ts | 4 +- src/app/store/users/users.effects.ts | 39 +-- src/app/store/users/users.reducer.ts | 75 +++--- src/environments/environment.prod.ts | 2 +- src/environments/environment.ts | 2 +- src/main.ts | 10 +- tsconfig.app.json | 1 + 92 files changed, 2656 insertions(+), 1127 deletions(-) create mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3a172c9 --- /dev/null +++ b/eslint.config.js @@ -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. diff --git a/package.json b/package.json index f764ad8..86f8a83 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server/dist/db.d.ts b/server/dist/db.d.ts index d3fbcaa..ffb67ba 100644 --- a/server/dist/db.d.ts +++ b/server/dist/db.d.ts @@ -1,43 +1,43 @@ export declare function initDB(): Promise; 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; export declare function getUserById(id: string): Promise; export declare function createUser(user: AuthUser): Promise; 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; export declare function getServerById(id: string): Promise; export declare function upsertServer(server: ServerInfo): Promise; export declare function deleteServer(id: string): Promise; 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; export declare function getJoinRequestById(id: string): Promise; export declare function getPendingRequestsForServer(serverId: string): Promise; export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise; export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise; -//# sourceMappingURL=db.d.ts.map \ No newline at end of file +// # sourceMappingURL=db.d.ts.map diff --git a/server/dist/index.d.ts b/server/dist/index.d.ts index e26a57a..0f8cc3e 100644 --- a/server/dist/index.d.ts +++ b/server/dist/index.d.ts @@ -1,2 +1,2 @@ export {}; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file +// # sourceMappingURL=index.d.ts.map diff --git a/server/src/db.ts b/server/src/db.ts index 3c683c4..654f871 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -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 { - 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 { } 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { 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 { } export async function deleteServer(id: string): Promise { - 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 { - 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 { } export async function getJoinRequestById(id: string): Promise { - 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 { - 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 { - 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 { - 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(); diff --git a/server/src/index.ts b/server/src/index.ts index 29d1148..08b5b08 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,8 +23,8 @@ app.use(express.json()); interface ConnectedUser { oderId: string; ws: WebSocket; - serverIds: Set; // all servers the user is a member of - viewedServerId?: string; // currently viewed/active server + serverIds: Set; // 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); + }); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 55b239f..3dd2532 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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 + }) + ] }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index fe79b3f..b4e0a37 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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) + } ]; diff --git a/src/app/app.ts b/src/app/app.ts index ba5c1e1..60c2653 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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); diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index d0d58cf..0df36cc 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -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 { const result: Record = {}; + 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, + attachmentMap: Record ): 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { - 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(); + 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 { 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; } } diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index e3de725..9f81ed7 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -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 { const url = `${this.endpointFor(params.serverId)}/users/register`; + return this.http.post(url, { username: params.username, password: params.password, - displayName: params.displayName, + displayName: params.displayName }); } @@ -90,9 +93,10 @@ export class AuthService { serverId?: string; }): Observable { const url = `${this.endpointFor(params.serverId)}/users/login`; + return this.http.post(url, { username: params.username, - password: params.password, + password: params.password }); } } diff --git a/src/app/core/services/browser-database.service.ts b/src/app/core/services/browser-database.service.ts index ae77a20..de6fa05 100644 --- a/src/app/core/services/browser-database.service.ts +++ b/src/app/core/services/browser-database.service.ts @@ -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 { - 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 { const allRoomMessages = await this.getAllFromIndex( - 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): Promise { const existing = await this.get(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 { const messages = await this.getAllFromIndex( - 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 { const existing = await this.getAllFromIndex( - 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 { const reactions = await this.getAllFromIndex( - 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 { 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): Promise { const existing = await this.get(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): Promise { const existing = await this.get(STORE_ROOMS, roomId); + if (existing) { await this.put(STORE_ROOMS, { ...existing, ...updates }); } @@ -208,10 +219,11 @@ export class BrowserDatabaseService { async removeBan(oderId: string): Promise { const allBans = await this.getAll(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 { const allBans = await this.getAllFromIndex( - 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 { 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 { const attachments = await this.getAllFromIndex( - 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 { 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( storeName: string, indexName: string, - key: IDBValidKey, + key: IDBValidKey ): Promise { 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 { 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 { return new Promise((resolve, reject) => { const transaction = this.createTransaction(storeName, 'readwrite'); + transaction.objectStore(storeName).delete(key); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); diff --git a/src/app/core/services/database.service.ts b/src/app/core/services/database.service.ts index ab247da..2367dcb 100644 --- a/src/app/core/services/database.service.ts +++ b/src/app/core/services/database.service.ts @@ -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'; diff --git a/src/app/core/services/electron-database.service.ts b/src/app/core/services/electron-database.service.ts index 9ff4b44..3957d16 100644 --- a/src/app/core/services/electron-database.service.ts +++ b/src/app/core/services/electron-database.service.ts @@ -20,7 +20,9 @@ export class ElectronDatabaseService { /** Initialise the SQLite database via the main-process IPC bridge. */ async initialize(): Promise { - if (this.isInitialised) return; + if (this.isInitialised) + return; + await this.api.initialize(); this.isInitialised = true; } diff --git a/src/app/core/services/external-link.service.ts b/src/app/core/services/external-link.service.ts index a4ba057..63fa8ad 100644 --- a/src/app/core/services/external-link.service.ts +++ b/src/app/core/services/external-link.service.ts @@ -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(); diff --git a/src/app/core/services/notification-audio.service.ts b/src/app/core/services/notification-audio.service.ts index 65efd14..a28e2b4 100644 --- a/src/app/core/services/notification-audio.service.ts +++ b/src/app/core/services/notification-audio.service.ts @@ -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 */ diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts index d8f78f1..f605a2b 100644 --- a/src/app/core/services/server-directory.service.ts +++ b/src/app/core/services/server-directory.service.ts @@ -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 = { 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 { 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 { 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 & { id?: string }, + server: Omit & { id?: string } ): Observable { return this.http .post(`${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, + updates: Partial ): Observable { return this.http .patch(`${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 { 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 { 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 { 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(items: T[]): T[] { const seen = new Set(); + 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(); } diff --git a/src/app/core/services/time-sync.service.ts b/src/app/core/services/time-sync.service.ts index a0f929c..0694fbb 100644 --- a/src/app/core/services/time-sync.service.ts +++ b/src/app/core/services/time-sync.service.ts @@ -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 { 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(); diff --git a/src/app/core/services/voice-activity.service.ts b/src/app/core/services/voice-activity.service.ts index 2ab2520..8c559bc 100644 --- a/src/app/core/services/voice-activity.service.ts +++ b/src/app/core/services/voice-activity.service.ts @@ -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 { 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 { 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(); + 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 */ } } diff --git a/src/app/core/services/voice-leveling.service.ts b/src/app/core/services/voice-leveling.service.ts index 1c1f49c..52d96ed 100644 --- a/src/app/core/services/voice-leveling.service.ts +++ b/src/app/core/services/voice-leveling.service.ts @@ -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; - 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 */ } } diff --git a/src/app/core/services/voice-session.service.ts b/src/app/core/services/voice-session.service.ts index 113c552..8769229 100644 --- a/src/app/core/services/voice-session.service.ts +++ b/src/app/core/services/voice-session.service.ts @@ -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); } diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index ca08cea..855f868 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -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 => 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 { 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 { + async startScreenShare(includeAudio = false): Promise { 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'; } diff --git a/src/app/core/services/webrtc/media.manager.ts b/src/app/core/services/webrtc/media.manager.ts index dbdad54..4dcf8f9 100644 --- a/src/app/core/services/webrtc/media.manager.ts +++ b/src/app/core/services/webrtc/media.manager.ts @@ -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 { 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 + } }); } diff --git a/src/app/core/services/webrtc/noise-reduction.manager.ts b/src/app/core/services/webrtc/noise-reduction.manager.ts index ecb972e..3dc8e50 100644 --- a/src/app/core/services/webrtc/noise-reduction.manager.ts +++ b/src/app/core/services/webrtc/noise-reduction.manager.ts @@ -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 { - 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; } diff --git a/src/app/core/services/webrtc/peer-connection.manager.ts b/src/app/core/services/webrtc/peer-connection.manager.ts index 8d9f531..6f421ee 100644 --- a/src/app/core/services/webrtc/peer-connection.manager.ts +++ b/src/app/core/services/webrtc/peer-connection.manager.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/core/services/webrtc/screen-share.manager.ts index 58c3299..5ca11a7 100644 --- a/src/app/core/services/webrtc/screen-share.manager.ts +++ b/src/app/core/services/webrtc/screen-share.manager.ts @@ -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 { + async startScreenShare(includeSystemAudio = false): Promise { 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; } } diff --git a/src/app/core/services/webrtc/signaling.manager.ts b/src/app/core/services/webrtc/signaling.manager.ts index e77381d..efa5203 100644 --- a/src/app/core/services/webrtc/signaling.manager.ts +++ b/src/app/core/services/webrtc/signaling.manager.ts @@ -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, + private readonly getMemberServerIds: () => ReadonlySet ) {} /** 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 { - if (this.isSocketOpen()) return true; - if (!this.lastSignalingUrl) return false; + if (this.isSocketOpen()) + return true; + + if (!this.lastSignalingUrl) + return false; return new Promise((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; } diff --git a/src/app/core/services/webrtc/voice-leveling.manager.ts b/src/app/core/services/webrtc/voice-leveling.manager.ts index 45b9831..0fdc69f 100644 --- a/src/app/core/services/webrtc/voice-leveling.manager.ts +++ b/src/app/core/services/webrtc/voice-leveling.manager.ts @@ -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 { // 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 { - 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 */ } } } diff --git a/src/app/core/services/webrtc/webrtc-logger.ts b/src/app/core/services/webrtc/webrtc-logger.ts index 51df1ae..09b6dc9 100644 --- a/src/app/core/services/webrtc/webrtc-logger.ts +++ b/src/app/core/services/webrtc/webrtc-logger.ts @@ -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}`)); diff --git a/src/app/core/services/webrtc/webrtc.constants.ts b/src/app/core/services/webrtc/webrtc.constants.ts index 61642dc..8cecf3d 100644 --- a/src/app/core/services/webrtc/webrtc.constants.ts +++ b/src/app/core/services/webrtc/webrtc.constants.ts @@ -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; diff --git a/src/app/features/admin/admin-panel/admin-panel.component.html b/src/app/features/admin/admin-panel/admin-panel.component.html index 21601af..48ce29d 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.html +++ b/src/app/features/admin/admin-panel/admin-panel.component.html @@ -9,6 +9,7 @@