diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..92c4b74 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist/ +release/ +node_modules/ +**/migrations/** +**/generated/** diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..38174a7 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,14 @@ +{ + "singleAttributePerLine": true, + "htmlWhitespaceSensitivity": "css", + "printWidth": 150, + "proseWrap": "preserve", + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/.vscode/settings.json b/.vscode/settings.json index c0f004d..8126718 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,8 +7,11 @@ } }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true + // "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "always" + } }, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" @@ -23,5 +26,5 @@ "prettier.printWidth": 150, "prettier.singleAttributePerLine": true, "prettier.htmlWhitespaceSensitivity": "css", - "prettier.tabWidth": 4 + "prettier.tabWidth": 2 } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 244306f..44b80f3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,41 +1,39 @@ { - // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 "version": "2.0.0", "tasks": [ { - "type": "npm", - "script": "start", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "Changes detected" - }, - "endsPattern": { - "regexp": "bundle generation (complete|failed)" - } - } - } + "label": "Sort Template Properties", + "type": "shell", + "command": "node", + "args": [ + "tools/sort-template-properties.js", + "${file}" + ], + "presentation": { + "reveal": "silent", + "panel": "shared" + }, + "runOptions": { + "runOn": "folderOpen" + }, + "problemMatcher": [] }, { - "type": "npm", - "script": "test", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "Changes detected" - }, - "endsPattern": { - "regexp": "bundle generation (complete|failed)" - } - } + "label": "Format HTML on Save", + "type": "shell", + "command": "npx", + "args": [ + "prettier", + "--write", + "${file}" + ], + "presentation": { + "reveal": "silent", + "panel": "shared" + }, + "problemMatcher": [], + "runOptions": { + "runOn": "folderOpen" } } ] diff --git a/angular.json b/angular.json index 4a00ccc..761a12e 100644 --- a/angular.json +++ b/angular.json @@ -3,7 +3,10 @@ "version": 1, "cli": { "packageManager": "npm", - "analytics": false + "analytics": false, + "schematicCollections": [ + "angular-eslint" + ] }, "newProjectRoot": "projects", "projects": { @@ -100,6 +103,15 @@ } }, "defaultConfiguration": "development" + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } } } } diff --git a/eslint.config.js b/eslint.config.js index 3a172c9..9351680 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,39 +1,57 @@ +// ESLint Flat Config for Weaver 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'); +const newlines = require('eslint-plugin-import-newlines'); + +// Inline plugin: ban en dash (–, U+2013) and em dash (—, U+2014) from source files +const noDashPlugin = { + rules: { + 'no-unicode-dashes': { + meta: { fixable: 'code' }, + create(context) { + const BANNED = [ + { char: '\u2013', name: 'en dash (–)' }, + { char: '\u2014', name: 'em dash (—)' } + ]; + return { + Program() { + const src = context.getSourceCode().getText(); + for (const { char, name } of BANNED) { + let idx = src.indexOf(char); + while (idx !== -1) { + const start = idx; + const end = idx + char.length; + context.report({ + loc: context.getSourceCode().getLocFromIndex(idx), + message: `Unicode ${name} is not allowed. Use a regular hyphen (-) instead.`, + fix(fixer) { + return fixer.replaceTextRange([start, end], '-'); + } + }); + idx = src.indexOf(char, idx + 1); + } + } + } + }; + } + } + } +}; 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' - } + ignores: ['**/generated/*','dist/**', '**/migrations/**', 'release/**'] }, { files: ['**/*.ts'], plugins: { '@stylistic/ts': stylisticTs, - '@stylistic/js': stylisticJs + '@stylistic/js': stylisticJs, + 'import-newlines': newlines, + 'no-dashes': noDashPlugin }, extends: [ eslint.configs.recommended, @@ -44,92 +62,38 @@ module.exports = tseslint.config( ], processor: angular.processInlineTemplates, rules: { + 'no-dashes/no-unicode-dashes': 'error', '@typescript-eslint/no-extraneous-class': 'off', - '@angular-eslint/component-class-suffix': ['error', { suffixes: ['Component', 'Page', 'Stub'] }], + '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ], '@angular-eslint/directive-class-suffix': 'error', + '@typescript-eslint/explicit-module-boundry-types': 'off', '@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/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' - ] - } - ], + '@stylistic/ts/indent': ['error',2,{ ignoredNodes:[ + 'TSTypeParameterInstantation', + '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', @@ -137,23 +101,23 @@ module.exports = tseslint.config( '@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-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/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-dangle': ['error','never'], '@stylistic/ts/comma-spacing': 'error', '@stylistic/js/comma-style': 'error', - complexity: ['warn', { max: 20 }], - curly: 'off', + '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 }], + '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', @@ -161,47 +125,70 @@ module.exports = tseslint.config( 'no-empty': 'off', 'no-eval': 'error', '@stylistic/js/no-multi-spaces': 'error', - '@stylistic/js/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }], + '@stylistic/js/no-multiple-empty-lines': ['error',{ max:1, maxEOF:1 }], 'no-new-wrappers': 'error', - 'no-restricted-imports': ['error', 'rxjs/Rx'], + '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'], + '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/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'], + '@stylistic/js/spaced-comment': ['error','always',{ markers:['/'] }], + "import-newlines/enforce": [ + "error", + 2 + ], + // Require spaces inside single-line blocks: { stmt; } + '@stylistic/js/block-spacing': ['error','always'], + + // Disallow single-line if statements but allow body on the next line (with or without braces) + // Examples allowed: + // if (condition)\n return true; + // if (condition)\n {\n return true;\n } 'nonblock-statement-body-position': ['error', 'below'], + // Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); } 'max-statements-per-line': ['error', { max: 1 }], + // Prevent single-character identifiers for variables/params; do not check object property names 'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }], + // Require blank lines around block-like statements (if, function, class, switch, try, etc.) 'padding-line-between-statements': [ 'error', + // Ensure blank lines around standalone if statements within the same scope { blankLine: 'always', prev: '*', next: 'if' }, { blankLine: 'always', prev: 'if', next: '*' }, + // Keep clear separation around any block-like statement (if, function, class, switch, try, etc.) { blankLine: 'always', prev: '*', next: 'block-like' }, { blankLine: 'always', prev: 'block-like', next: '*' }, - { blankLine: 'always', prev: 'function', next: '*' }, - { blankLine: 'always', prev: 'class', next: '*' }, + // Always require a blank line after functions (and multiline expressions) + { blankLine: 'always', prev: ['function', 'multiline-expression'], next: '*' }, + // Always require a blank line after class declarations (and multiline expressions) + { blankLine: 'always', prev: ['class', 'multiline-expression'], next: '*' }, + // Always require a blank line after groups of variable declarations { blankLine: 'always', prev: 'const', next: '*' }, { blankLine: 'always', prev: 'let', next: '*' }, { blankLine: 'always', prev: 'var', next: '*' }, + // But never require a blank line between a series of variable declarations of the same kind { blankLine: 'never', prev: 'const', next: 'const' }, { blankLine: 'never', prev: 'let', next: 'let' }, { blankLine: 'never', prev: 'var', next: 'var' } ] } }, + // HTML template formatting rules (external Angular templates only) { files: ['src/app/**/*.html'], + plugins: { 'no-dashes': noDashPlugin }, extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: { + 'no-dashes/no-unicode-dashes': 'error', + // Angular template best practices '@angular-eslint/template/button-has-type': 'warn', '@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }], '@angular-eslint/template/eqeqeq': 'error', @@ -210,9 +197,13 @@ module.exports = tseslint.config( '@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' - } - } + '@angular-eslint/template/no-call-expression': 'off', // Allow method calls in templates + // Note: attributes-order is disabled in favor of Prettier handling formatting + // Prettier uses singleAttributePerLine to enforce property grouping + }, + }, ); -// IMPORTANT: Formatting is handled by Prettier; ESLint validates logic/accessibility. +// IMPORTANT: Formatting is handled by Prettier, not ESLint +// ESLint validates logic/accessibility, Prettier handles formatting +// Enable format on save in VS Code settings to use Prettier automatically diff --git a/package.json b/package.json index 86f8a83..1893d18 100644 --- a/package.json +++ b/package.json @@ -29,19 +29,11 @@ "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", - "lint": "eslint . --ext .ts,.html" - }, - "prettier": { - "printWidth": 100, - "singleQuote": true, - "overrides": [ - { - "files": "*.html", - "options": { - "parser": "angular" - } - } - ] + "lint": "eslint .", + "lint:fix": "npm run format && npm run sort:props && eslint . --fix", + "format": "prettier --write \"src/app/**/*.html\"", + "format:check": "prettier --check \"src/app/**/*.html\"", + "sort:props": "node tools/sort-template-properties.js" }, "private": true, "packageManager": "npm@10.9.2", @@ -84,17 +76,22 @@ "@stylistic/eslint-plugin-ts": "^4.4.1", "@types/simple-peer": "^9.11.9", "@types/uuid": "^10.0.0", - "angular-eslint": "^21.2.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", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-newlines": "^1.4.1", + "eslint-plugin-prettier": "^5.5.5", + "glob": "^10.5.0", "postcss": "^8.5.6", + "prettier": "^3.8.1", "tailwindcss": "^3.4.19", "typescript": "~5.9.2", - "typescript-eslint": "^8.56.1", + "typescript-eslint": "8.50.1", "wait-on": "^7.2.0" }, "build": { diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 6252c44..51a30a0 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 3dd2532..1adbabb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,8 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core'; +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + isDevMode +} from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideStore } from '@ngrx/store'; @@ -26,7 +30,12 @@ export const appConfig: ApplicationConfig = { users: usersReducer, rooms: roomsReducer }), - provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]), + provideEffects([ + MessagesEffects, + MessagesSyncEffects, + UsersEffects, + RoomsEffects + ]), provideStoreDevtools({ maxAge: STORE_DEVTOOLS_MAX_AGE, logOnly: !isDevMode(), diff --git a/src/app/app.ts b/src/app/app.ts index 60c2653..9cfa5ac 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,6 +1,15 @@ /* 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 { + Component, + OnInit, + inject, + HostListener +} from '@angular/core'; +import { + Router, + RouterOutlet, + NavigationEnd +} from '@angular/router'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index 4481186..dfa516d 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -29,7 +29,7 @@ export const DEFAULT_MAX_USERS = 50; /** Default audio bitrate in kbps for voice chat. */ export const DEFAULT_AUDIO_BITRATE_KBPS = 96; -/** Default volume level (0–100). */ +/** Default volume level (0-100). */ export const DEFAULT_VOLUME = 100; /** Default search debounce time in milliseconds. */ diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index 7ecb16e..982d6df 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -306,6 +306,7 @@ export type ChatEventType = | 'room-deleted' | 'room-settings-update' | 'voice-state' + | 'chat-inventory-request' | 'voice-state-request' | 'state-request' | 'screen-state' diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index 0df36cc..1631db6 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */ -import { Injectable, inject, signal, effect } from '@angular/core'; +import { + Injectable, + inject, + signal, + effect +} from '@angular/core'; import { v4 as uuidv4 } from 'uuid'; import { WebRTCService } from './webrtc.service'; import { Store } from '@ngrx/store'; @@ -168,7 +173,7 @@ export class AttachmentService { /** * Register attachment metadata received via message sync - * (content is not yet available — only metadata). + * (content is not yet available - only metadata). * * @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer. */ @@ -184,7 +189,9 @@ export class AttachmentService { const alreadyKnown = existing.find((entry) => entry.id === meta.id); if (!alreadyKnown) { - const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 }; + const attachment: Attachment = { ...meta, + available: false, + receivedBytes: 0 }; existing.push(attachment); newAttachments.push(attachment); @@ -228,7 +235,7 @@ export class AttachmentService { } /** - * Handle a `file-not-found` response — try the next available peer. + * Handle a `file-not-found` response - try the next available peer. */ handleFileNotFound(payload: any): void { const { messageId, fileId } = payload; @@ -428,6 +435,7 @@ export class AttachmentService { attachment.speedBps = EWMA_PREVIOUS_WEIGHT * previousSpeed + EWMA_CURRENT_WEIGHT * instantaneousBps; + attachment.lastUpdateMs = now; this.touch(); // trigger UI update for progress bars @@ -600,7 +608,7 @@ export class AttachmentService { } /** - * Handle a `file-cancel` from the requester — record the + * Handle a `file-cancel` from the requester - record the * cancellation so the streaming loop breaks early. */ handleFileCancel(payload: any): void { @@ -690,6 +698,7 @@ export class AttachmentService { messageId, fileId } as any); + return true; } @@ -923,7 +932,8 @@ export class AttachmentService { const grouped = new Map(); for (const record of allRecords) { - const attachment: Attachment = { ...record, available: false }; + const attachment: Attachment = { ...record, + available: false }; const bucket = grouped.get(record.messageId) ?? []; bucket.push(attachment); @@ -949,7 +959,8 @@ export class AttachmentService { const existing = this.attachmentsByMessage.get(meta.messageId) ?? []; if (!existing.find((entry) => entry.id === meta.id)) { - const attachment: Attachment = { ...meta, available: false }; + const attachment: Attachment = { ...meta, + available: false }; existing.push(attachment); this.attachmentsByMessage.set(meta.messageId, existing); diff --git a/src/app/core/services/browser-database.service.ts b/src/app/core/services/browser-database.service.ts index de6fa05..4555c3b 100644 --- a/src/app/core/services/browser-database.service.ts +++ b/src/app/core/services/browser-database.service.ts @@ -1,10 +1,16 @@ /* 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'; +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. */ +/** 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'; @@ -77,7 +83,8 @@ export class BrowserDatabaseService { const existing = await this.get(STORE_MESSAGES, messageId); if (existing) { - await this.put(STORE_MESSAGES, { ...existing, ...updates }); + await this.put(STORE_MESSAGES, { ...existing, + ...updates }); } } @@ -160,7 +167,8 @@ export class BrowserDatabaseService { /** Store which user ID is considered "current" (logged-in). */ async setCurrentUserId(userId: string): Promise { - await this.put(STORE_META, { id: 'currentUserId', value: userId }); + await this.put(STORE_META, { id: 'currentUserId', + value: userId }); } /** @@ -176,7 +184,8 @@ export class BrowserDatabaseService { const existing = await this.get(STORE_USERS, userId); if (existing) { - await this.put(STORE_USERS, { ...existing, ...updates }); + await this.put(STORE_USERS, { ...existing, + ...updates }); } } @@ -206,7 +215,8 @@ export class BrowserDatabaseService { const existing = await this.get(STORE_ROOMS, roomId); if (existing) { - await this.put(STORE_ROOMS, { ...existing, ...updates }); + await this.put(STORE_ROOMS, { ...existing, + ...updates }); } } @@ -296,7 +306,7 @@ export class BrowserDatabaseService { } // ══════════════════════════════════════════════════════════════════ - // Private helpers — thin wrappers around IndexedDB + // Private helpers - thin wrappers around IndexedDB // ══════════════════════════════════════════════════════════════════ /** diff --git a/src/app/core/services/database.service.ts b/src/app/core/services/database.service.ts index 2367dcb..fe8ef74 100644 --- a/src/app/core/services/database.service.ts +++ b/src/app/core/services/database.service.ts @@ -1,6 +1,16 @@ /* 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 { + inject, + Injectable, + signal +} from '@angular/core'; +import { + Message, + User, + Room, + Reaction, + BanEntry +} from '../models'; import { PlatformService } from './platform.service'; import { BrowserDatabaseService } from './browser-database.service'; import { ElectronDatabaseService } from './electron-database.service'; @@ -12,7 +22,7 @@ import { ElectronDatabaseService } from './electron-database.service'; * - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process). * - **Browser** → IndexedDB via {@link BrowserDatabaseService}. * - * All consumers inject `DatabaseService` — the underlying storage engine + * All consumers inject `DatabaseService` - the underlying storage engine * is selected automatically. */ @Injectable({ providedIn: 'root' }) diff --git a/src/app/core/services/electron-database.service.ts b/src/app/core/services/electron-database.service.ts index 3957d16..5845e87 100644 --- a/src/app/core/services/electron-database.service.ts +++ b/src/app/core/services/electron-database.service.ts @@ -1,5 +1,11 @@ import { Injectable } from '@angular/core'; -import { Message, User, Room, Reaction, BanEntry } from '../models'; +import { + Message, + User, + Room, + Reaction, + BanEntry +} from '../models'; /** * Database service for the Electron (desktop) runtime. diff --git a/src/app/core/services/notification-audio.service.ts b/src/app/core/services/notification-audio.service.ts index a28e2b4..59781f4 100644 --- a/src/app/core/services/notification-audio.service.ts +++ b/src/app/core/services/notification-audio.service.ts @@ -18,7 +18,7 @@ const AUDIO_BASE = '/assets/audio'; const AUDIO_EXT = 'wav'; /** localStorage key for persisting notification volume. */ const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume'; -/** Default notification volume (0 – 1). */ +/** Default notification volume (0 - 1). */ const DEFAULT_VOLUME = 0.2; /** @@ -36,7 +36,7 @@ export class NotificationAudioService { /** Pre-loaded audio buffers keyed by {@link AppSound}. */ private readonly cache = new Map(); - /** Reactive notification volume (0 – 1), persisted to localStorage. */ + /** Reactive notification volume (0 - 1), persisted to localStorage. */ readonly notificationVolume = signal(this.loadVolume()); constructor() { @@ -88,10 +88,10 @@ export class NotificationAudioService { * Play a sound effect at the current notification volume. * * If playback fails (e.g. browser autoplay policy) the error is - * silently swallowed — sound effects are non-critical. + * silently swallowed - sound effects are non-critical. * * @param sound - The {@link AppSound} to play. - * @param volumeOverride - Optional explicit volume (0 – 1). When omitted + * @param volumeOverride - Optional explicit volume (0 - 1). When omitted * the persisted {@link notificationVolume} is used. */ play(sound: AppSound, volumeOverride?: number): void { diff --git a/src/app/core/services/platform.service.ts b/src/app/core/services/platform.service.ts index 5a39a2a..1e79aaf 100644 --- a/src/app/core/services/platform.service.ts +++ b/src/app/core/services/platform.service.ts @@ -15,6 +15,7 @@ export class PlatformService { constructor() { this.isElectron = typeof window !== 'undefined' && !!(window as any).electronAPI; + this.isBrowser = !this.isElectron; } } diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts index f605a2b..ae36273 100644 --- a/src/app/core/services/server-directory.service.ts +++ b/src/app/core/services/server-directory.service.ts @@ -1,9 +1,22 @@ /* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */ -import { Injectable, signal, computed } from '@angular/core'; +import { + Injectable, + signal, + computed +} from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable, of, throwError, forkJoin } from 'rxjs'; +import { + Observable, + of, + throwError, + forkJoin +} from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { ServerInfo, JoinRequest, User } from '../models'; +import { + ServerInfo, + JoinRequest, + User +} from '../models'; import { v4 as uuidv4 } from 'uuid'; /** @@ -137,6 +150,7 @@ export class ServerDirectoryService { isActive: endpoint.id === endpointId })) ); + this.saveEndpoints(); } @@ -148,9 +162,12 @@ export class ServerDirectoryService { ): void { this._servers.update((endpoints) => endpoints.map((endpoint) => - endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint + endpoint.id === endpointId ? { ...endpoint, + status, + latency } : endpoint ) ); + this.saveEndpoints(); } @@ -541,7 +558,8 @@ export class ServerDirectoryService { endpoints = endpoints.map((endpoint) => { if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) { - return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) }; + return { ...endpoint, + url: endpoint.url.replace(/^https?/, expectedProtocol) }; } return endpoint; @@ -556,7 +574,8 @@ export class ServerDirectoryService { /** Create and persist the built-in default endpoint. */ private initialiseDefaultEndpoint(): void { - const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() }; + 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 0694fbb..59fcd7d 100644 --- a/src/app/core/services/time-sync.service.ts +++ b/src/app/core/services/time-sync.service.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/member-ordering */ -import { Injectable, signal, computed } from '@angular/core'; +import { + Injectable, + signal, + computed +} from '@angular/core'; /** Default timeout (ms) for the NTP-style HTTP sync request. */ const DEFAULT_SYNC_TIMEOUT_MS = 5000; diff --git a/src/app/core/services/voice-activity.service.ts b/src/app/core/services/voice-activity.service.ts index 8c559bc..a947602 100644 --- a/src/app/core/services/voice-activity.service.ts +++ b/src/app/core/services/voice-activity.service.ts @@ -1,5 +1,5 @@ /** - * VoiceActivityService — monitors audio levels for local microphone + * VoiceActivityService - monitors audio levels for local microphone * and remote peer streams, exposing per-user "speaking" state as * reactive Angular signals. * @@ -9,19 +9,26 @@ * // speaking() => true when the user's audio level exceeds the threshold * * const volume = voiceActivity.volume(userId); - * // volume() => normalised 0–1 audio level + * // volume() => normalised 0-1 audio level * ``` * * Internally uses the Web Audio API ({@link AudioContext} + * {@link AnalyserNode}) per tracked stream, with a single * `requestAnimationFrame` poll loop. */ -import { Injectable, signal, computed, inject, OnDestroy, Signal } from '@angular/core'; +import { + Injectable, + signal, + computed, + inject, + OnDestroy, + Signal +} from '@angular/core'; import { Subscription } from 'rxjs'; import { 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". */ +/** 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; @@ -38,7 +45,7 @@ interface TrackedStream { analyser: AnalyserNode; /** Reusable buffer for `getByteTimeDomainData`. */ dataArray: Uint8Array; - /** Writable signal for the normalised volume (0–1). */ + /** Writable signal for the normalised volume (0-1). */ volumeSignal: ReturnType>; /** Writable signal for speaking state. */ speakingSignal: ReturnType>; @@ -123,7 +130,7 @@ export class VoiceActivityService implements OnDestroy { } /** - * Returns a read-only signal with the normalised (0–1) volume + * Returns a read-only signal with the normalised (0-1) volume * for the given user. */ volume(userId: string): Signal { @@ -160,7 +167,7 @@ export class VoiceActivityService implements OnDestroy { analyser.fftSize = FFT_SIZE; source.connect(analyser); - // Do NOT connect analyser to ctx.destination — we don't want to + // Do NOT connect analyser to ctx.destination - we don't want to // double-play audio; playback is handled elsewhere. const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array; @@ -226,7 +233,7 @@ export class VoiceActivityService implements OnDestroy { analyser.getByteTimeDomainData(dataArray); - // Compute RMS volume from time-domain data (values 0–255, centred at 128). + // Compute RMS volume from time-domain data (values 0-255, centred at 128). let sumSquares = 0; for (let i = 0; i < dataArray.length; i++) { @@ -271,6 +278,7 @@ export class VoiceActivityService implements OnDestroy { this.tracked.forEach((entry, id) => { map.set(id, entry.speakingSignal()); }); + this._speakingMap.set(map); } diff --git a/src/app/core/services/voice-leveling.service.ts b/src/app/core/services/voice-leveling.service.ts index 52d96ed..34ad60f 100644 --- a/src/app/core/services/voice-leveling.service.ts +++ b/src/app/core/services/voice-leveling.service.ts @@ -1,5 +1,5 @@ /** - * VoiceLevelingService — Angular service that manages the + * VoiceLevelingService - Angular service that manages the * per-speaker voice leveling (AGC) system. * * ═══════════════════════════════════════════════════════════════════ @@ -17,7 +17,7 @@ * * 4. Provides an `enable` / `disable` / `disableAll` API that * the voice-controls component uses to insert and remove the - * AGC pipeline from the remote audio playback chain — mirroring + * AGC pipeline from the remote audio playback chain - mirroring * the {@link NoiseReductionManager} toggle pattern. * * 5. Fires a callback when the user toggles the enabled state so @@ -26,7 +26,12 @@ * ═══════════════════════════════════════════════════════════════════ */ /* eslint-disable @typescript-eslint/member-ordering */ -import { Injectable, signal, computed, OnDestroy } from '@angular/core'; +import { + Injectable, + signal, + computed, + OnDestroy +} from '@angular/core'; import { VoiceLevelingManager, VoiceLevelingSettings, @@ -167,7 +172,7 @@ export class VoiceLevelingService implements OnDestroy { * Set the post-AGC volume for a specific speaker. * * @param peerId The speaker's peer ID. - * @param volume Normalised volume (0–1). + * @param volume Normalised volume (0-1). */ setSpeakerVolume(peerId: string, volume: number): void { this.manager.setSpeakerVolume(peerId, volume); @@ -176,7 +181,7 @@ export class VoiceLevelingService implements OnDestroy { /** * Set the master volume applied after AGC to all speakers. * - * @param volume Normalised volume (0–1). + * @param volume Normalised volume (0-1). */ setMasterVolume(volume: number): void { this.manager.setMasterVolume(volume); @@ -222,7 +227,7 @@ export class VoiceLevelingService implements OnDestroy { STORAGE_KEY_VOICE_LEVELING_SETTINGS, JSON.stringify(settings) ); - } catch { /* localStorage unavailable — ignore */ } + } catch { /* localStorage unavailable - ignore */ } } /** Load settings from localStorage and apply to the manager. */ @@ -264,7 +269,7 @@ export class VoiceLevelingService implements OnDestroy { speed: this._speed(), noiseGate: this._noiseGate() }); - } catch { /* corrupted data — use defaults */ } + } catch { /* corrupted data - use defaults */ } } /* ── Cleanup ─────────────────────────────────────────────────── */ diff --git a/src/app/core/services/voice-session.service.ts b/src/app/core/services/voice-session.service.ts index 8769229..03f6231 100644 --- a/src/app/core/services/voice-session.service.ts +++ b/src/app/core/services/voice-session.service.ts @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ -import { Injectable, signal, computed, inject } from '@angular/core'; +import { + Injectable, + signal, + computed, + inject +} from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { RoomsActions } from '../../store/rooms/rooms.actions'; @@ -31,7 +36,7 @@ export interface VoiceSessionInfo { * navigation so that floating voice controls remain visible when * the user is browsing a different server or view. * - * This service is purely a UI-state tracker — actual WebRTC + * This service is purely a UI-state tracker - actual WebRTC * voice management lives in {@link WebRTCService} and its managers. */ @Injectable({ providedIn: 'root' }) @@ -132,6 +137,7 @@ export class VoiceSessionService { } 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 855f868..20ee66d 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -1,36 +1,39 @@ /** - * WebRTCService — thin Angular service that composes specialised managers. + * WebRTCService - thin Angular service that composes specialised managers. * * Each concern lives in its own file under `./webrtc/`: - * • SignalingManager – WebSocket lifecycle & reconnection - * • PeerConnectionManager – RTCPeerConnection, offers/answers, ICE, data channels - * • MediaManager – mic voice, mute, deafen, bitrate - * • ScreenShareManager – screen capture & mixed audio - * • WebRTCLogger – debug / diagnostic logging + * • SignalingManager - WebSocket lifecycle & reconnection + * • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels + * • MediaManager - mic voice, mute, deafen, bitrate + * • ScreenShareManager - screen capture & mixed audio + * • WebRTCLogger - debug / diagnostic logging * * 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 { + Injectable, + signal, + computed, + inject, + OnDestroy +} from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { SignalingMessage, ChatEvent } from '../models'; import { TimeSyncService } from './time-sync.service'; import { - // Managers SignalingManager, PeerConnectionManager, MediaManager, ScreenShareManager, WebRTCLogger, - // Types IdentifyCredentials, JoinedServerInfo, VoiceStateSnapshot, LatencyProfile, - // Constants SIGNALING_TYPE_IDENTIFY, SIGNALING_TYPE_JOIN_SERVER, SIGNALING_TYPE_VIEW_SERVER, @@ -255,6 +258,7 @@ export class WebRTCService implements OnDestroy { oderId: user.oderId, serverId: message.serverId }); + this.peerManager.createPeerConnection(user.oderId, true); this.peerManager.createAndSendOffer(user.oderId); @@ -273,6 +277,7 @@ export class WebRTCService implements OnDestroy { displayName: message.displayName, oderId: message.oderId }); + break; case SIGNALING_TYPE_USER_LEFT: @@ -337,7 +342,9 @@ export class WebRTCService implements OnDestroy { }); for (const peerId of peersToClose) { - this.logger.info('Closing peer from different server', { peerId, currentServer: serverId }); + this.logger.info('Closing peer from different server', { peerId, + currentServer: serverId }); + this.peerManager.removePeer(peerId); this.peerServerMap.delete(peerId); } @@ -355,7 +362,7 @@ export class WebRTCService implements OnDestroy { } // ═══════════════════════════════════════════════════════════════════ - // PUBLIC API – matches the old monolithic service's interface + // PUBLIC API - matches the old monolithic service's interface // ═══════════════════════════════════════════════════════════════════ /** @@ -414,8 +421,12 @@ export class WebRTCService implements OnDestroy { * @param displayName - The user's display name. */ identify(oderId: string, displayName: string): void { - this.lastIdentifyCredentials = { oderId, displayName }; - this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName }); + this.lastIdentifyCredentials = { oderId, + displayName }; + + this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, + oderId, + displayName }); } /** @@ -425,9 +436,12 @@ export class WebRTCService implements OnDestroy { * @param userId - The local user ID. */ joinRoom(roomId: string, userId: string): void { - this.lastJoinedServer = { serverId: roomId, userId }; + this.lastJoinedServer = { serverId: roomId, + userId }; + this.memberServerIds.add(roomId); - this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId }); + this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, + serverId: roomId }); } /** @@ -438,10 +452,13 @@ export class WebRTCService implements OnDestroy { * @param userId - The local user ID. */ switchServer(serverId: string, userId: string): void { - this.lastJoinedServer = { serverId, userId }; + this.lastJoinedServer = { serverId, + userId }; if (this.memberServerIds.has(serverId)) { - this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId }); + this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, + serverId }); + this.logger.info('Viewed server (already joined)', { serverId, userId, @@ -449,7 +466,9 @@ export class WebRTCService implements OnDestroy { }); } else { this.memberServerIds.add(serverId); - this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId }); + this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, + serverId }); + this.logger.info('Joined new server via switch', { serverId, userId, @@ -469,7 +488,9 @@ export class WebRTCService implements OnDestroy { leaveRoom(serverId?: string): void { if (serverId) { this.memberServerIds.delete(serverId); - this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId }); + this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, + serverId }); + this.logger.info('Left server', { serverId }); if (this.memberServerIds.size === 0) { @@ -480,8 +501,10 @@ export class WebRTCService implements OnDestroy { } this.memberServerIds.forEach((sid) => { - this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId: sid }); + this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, + serverId: sid }); }); + this.memberServerIds.clear(); this.fullCleanup(); } @@ -619,7 +642,7 @@ export class WebRTCService implements OnDestroy { /** * Set the output volume for remote audio playback. * - * @param volume - Normalised volume (0–1). + * @param volume - Normalised volume (0-1). */ setOutputVolume(volume: number): void { this.mediaManager.setOutputVolume(volume); diff --git a/src/app/core/services/webrtc/media.manager.ts b/src/app/core/services/webrtc/media.manager.ts index 4dcf8f9..4e08f9c 100644 --- a/src/app/core/services/webrtc/media.manager.ts +++ b/src/app/core/services/webrtc/media.manager.ts @@ -114,7 +114,7 @@ export class MediaManager { getIsSelfDeafened(): boolean { return this.isSelfDeafened; } - /** Current remote audio output volume (normalised 0–1). */ + /** Current remote audio output volume (normalised 0-1). */ getRemoteAudioVolume(): number { return this.remoteAudioVolume; } @@ -231,7 +231,7 @@ export class MediaManager { */ async setLocalStream(stream: MediaStream): Promise { this.rawMicStream = stream; - this.logger.info('setLocalStream — noiseReductionDesired =', this._noiseReductionDesired); + this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired); // Pipe through the denoiser when the user wants noise reduction if (this._noiseReductionDesired) { @@ -259,6 +259,7 @@ export class MediaManager { audioTracks.forEach((track) => { track.enabled = !newMutedState; }); + this.isMicMuted = newMutedState; } } @@ -299,8 +300,9 @@ export class MediaManager { 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; } diff --git a/src/app/core/services/webrtc/noise-reduction.manager.ts b/src/app/core/services/webrtc/noise-reduction.manager.ts index 3dc8e50..d11596b 100644 --- a/src/app/core/services/webrtc/noise-reduction.manager.ts +++ b/src/app/core/services/webrtc/noise-reduction.manager.ts @@ -18,7 +18,7 @@ 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. */ +/** 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** diff --git a/src/app/core/services/webrtc/peer-connection.manager.ts b/src/app/core/services/webrtc/peer-connection.manager.ts index 6f421ee..ad08001 100644 --- a/src/app/core/services/webrtc/peer-connection.manager.ts +++ b/src/app/core/services/webrtc/peer-connection.manager.ts @@ -123,7 +123,8 @@ export class PeerConnectionManager { * @returns The newly-created {@link PeerData} record. */ createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData { - this.logger.info('Creating peer connection', { remotePeerId, isInitiator }); + this.logger.info('Creating peer connection', { remotePeerId, + isInitiator }); const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS }); @@ -136,6 +137,7 @@ export class PeerConnectionManager { remotePeerId, candidateType: (event.candidate as any)?.type }); + this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_ICE_CANDIDATE, targetUserId: remotePeerId, @@ -182,7 +184,8 @@ export class PeerConnectionManager { }; connection.onsignalingstatechange = () => { - this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState }); + this.logger.info('signalingstatechange', { remotePeerId, + state: connection.signalingState }); }; connection.onnegotiationneeded = () => { @@ -302,6 +305,7 @@ export class PeerConnectionManager { type: offer.type, sdpLength: offer.sdp?.length }); + this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_OFFER, targetUserId: remotePeerId, @@ -370,11 +374,15 @@ export class PeerConnectionManager { const isPolite = localId > fromUserId; if (!isPolite) { - this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId }); - return; // Our offer takes priority – remote will answer it. + this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId, + localId }); + + return; // Our offer takes priority - remote will answer it. } - this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId }); + this.logger.info('Rolling back local offer (polite side)', { fromUserId, + localId }); + await peerData.connection.setLocalDescription({ type: 'rollback' } as RTCSessionDescriptionInit); @@ -438,6 +446,7 @@ export class PeerConnectionManager { type: answer.type, sdpLength: answer.sdp?.length }); + this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_ANSWER, targetUserId: fromUserId, @@ -482,7 +491,7 @@ export class PeerConnectionManager { peerData.pendingIceCandidates = []; } else { - this.logger.warn('Ignoring answer – wrong signaling state', { + this.logger.warn('Ignoring answer - wrong signaling state', { state: peerData.connection.signalingState }); } @@ -559,6 +568,7 @@ export class PeerConnectionManager { type: offer.type, sdpLength: offer.sdp?.length }); + this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_OFFER, targetUserId: peerId, @@ -622,16 +632,19 @@ export class PeerConnectionManager { * @param message - The parsed JSON payload. */ private handlePeerMessage(peerId: string, message: any): void { - this.logger.info('Received P2P message', { peerId, type: message?.type }); + this.logger.info('Received P2P message', { peerId, + type: message?.type }); if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) { this.sendCurrentStatesToPeer(peerId); return; } - // Ping/pong latency measurement – handled internally, not forwarded + // Ping/pong latency measurement - handled internally, not forwarded if (message.type === P2P_TYPE_PING) { - this.sendToPeer(peerId, { type: P2P_TYPE_PONG, ts: message.ts } as any); + this.sendToPeer(peerId, { type: P2P_TYPE_PONG, + ts: message.ts } as any); + return; } @@ -642,14 +655,16 @@ export class PeerConnectionManager { const latencyMs = Math.round(performance.now() - sent); this.peerLatencies.set(peerId, latencyMs); - this.peerLatencyChanged$.next({ peerId, latencyMs }); + this.peerLatencyChanged$.next({ peerId, + latencyMs }); } this.pendingPings.delete(peerId); return; } - const enriched = { ...message, fromPeerId: peerId }; + const enriched = { ...message, + fromPeerId: peerId }; this.messageReceived$.next(enriched); } @@ -682,7 +697,7 @@ export class PeerConnectionManager { 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 }); + this.logger.warn('Peer not connected - cannot send', { peerId }); return; } @@ -706,7 +721,7 @@ export class PeerConnectionManager { 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 }); + this.logger.warn('Peer not connected - cannot send buffered', { peerId }); return; } @@ -748,7 +763,11 @@ export class PeerConnectionManager { const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME; const voiceState = this.callbacks.getVoiceStateSnapshot(); - this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); + this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, + oderId, + displayName, + voiceState } as any); + this.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_STATE, oderId, @@ -759,10 +778,11 @@ export class PeerConnectionManager { private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void { if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) { - this.logger.warn('Cannot send states – channel not open', { + this.logger.warn('Cannot send states - channel not open', { remotePeerId, state: channel.readyState }); + return; } @@ -772,7 +792,11 @@ export class PeerConnectionManager { const voiceState = this.callbacks.getVoiceStateSnapshot(); try { - channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState })); + channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, + oderId, + displayName, + voiceState })); + channel.send( JSON.stringify({ type: P2P_TYPE_SCREEN_STATE, @@ -781,7 +805,9 @@ export class PeerConnectionManager { isScreenSharing: this.callbacks.isScreenSharingActive() }) ); - this.logger.info('Sent initial states to channel', { remotePeerId, voiceState }); + + this.logger.info('Sent initial states to channel', { remotePeerId, + voiceState }); } catch (e) { this.logger.error('Failed to send initial states to channel', e); } @@ -794,7 +820,11 @@ export class PeerConnectionManager { const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME; const voiceState = this.callbacks.getVoiceStateSnapshot(); - this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); + this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, + oderId, + displayName, + voiceState } as any); + this.broadcastMessage({ type: P2P_TYPE_SCREEN_STATE, oderId, @@ -816,6 +846,7 @@ export class PeerConnectionManager { readyState: track.readyState, settings }); + this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`); // Skip inactive video placeholder tracks @@ -825,6 +856,7 @@ export class PeerConnectionManager { enabled: track.enabled, readyState: track.readyState }); + return; } @@ -843,7 +875,8 @@ export class PeerConnectionManager { } this.remotePeerStreams.set(remotePeerId, compositeStream); - this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream }); + this.remoteStream$.next({ peerId: remotePeerId, + stream: compositeStream }); } /** @@ -879,6 +912,7 @@ export class PeerConnectionManager { peerData.connection.close(); }); + this.activePeerConnections.clear(); this.peerNegotiationQueue.clear(); this.peerLatencies.clear(); @@ -924,7 +958,8 @@ export class PeerConnectionManager { } info.reconnectAttempts++; - this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts }); + this.logger.info('P2P reconnect attempt', { peerId, + attempt: info.reconnectAttempts }); if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) { this.logger.info('P2P reconnect max attempts reached', { peerId }); @@ -934,7 +969,7 @@ export class PeerConnectionManager { } if (!this.callbacks.isSignalingConnected()) { - this.logger.info('Skipping P2P reconnect – no signaling connection', { peerId }); + this.logger.info('Skipping P2P reconnect - no signaling connection', { peerId }); return; } @@ -996,6 +1031,7 @@ export class PeerConnectionManager { this.connectedPeersList = this.connectedPeersList.filter( (connectedId) => connectedId !== peerId ); + this.connectedPeersChanged$.next(this.connectedPeersList); } @@ -1047,7 +1083,8 @@ export class PeerConnectionManager { this.pendingPings.set(peerId, ts); try { - peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts })); + peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, + ts })); } catch { /* ignore */ } diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/core/services/webrtc/screen-share.manager.ts index 5ca11a7..190150e 100644 --- a/src/app/core/services/webrtc/screen-share.manager.ts +++ b/src/app/core/services/webrtc/screen-share.manager.ts @@ -80,11 +80,13 @@ export class ScreenShareManager { 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 } }; + electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', + chromeMediaSourceId: screenSource.id } }; } else { electronConstraints.audio = false; } @@ -109,7 +111,9 @@ export class ScreenShareManager { height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT }, 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); diff --git a/src/app/core/services/webrtc/signaling.manager.ts b/src/app/core/services/webrtc/signaling.manager.ts index efa5203..297e888 100644 --- a/src/app/core/services/webrtc/signaling.manager.ts +++ b/src/app/core/services/webrtc/signaling.manager.ts @@ -24,7 +24,7 @@ export class SignalingManager { private signalingReconnectTimer: ReturnType | null = null; private stateHeartbeatTimer: ReturnType | null = null; - /** Fires every heartbeat tick – the main service hooks this to broadcast state. */ + /** Fires every heartbeat tick - the main service hooks this to broadcast state. */ readonly heartbeatTick$ = new Subject(); /** Fires whenever a raw signaling message arrives from the server. */ @@ -73,14 +73,18 @@ export class SignalingManager { this.signalingWebSocket.onerror = (error) => { this.logger.error('Signaling socket error', error); - this.connectionStatus$.next({ connected: false, errorMessage: 'Connection to signaling server failed' }); + this.connectionStatus$.next({ connected: false, + errorMessage: 'Connection to signaling server failed' }); + observer.error(error); }; this.signalingWebSocket.onclose = () => { this.logger.info('Disconnected from signaling server'); this.stopHeartbeat(); - this.connectionStatus$.next({ connected: false, errorMessage: 'Disconnected from signaling server' }); + this.connectionStatus$.next({ connected: false, + errorMessage: 'Disconnected from signaling server' }); + this.scheduleReconnect(); }; } catch (error) { @@ -118,7 +122,9 @@ export class SignalingManager { return; } - const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() }; + const fullMessage: SignalingMessage = { ...message, + from: localPeerId, + timestamp: Date.now() }; this.signalingWebSocket!.send(JSON.stringify(fullMessage)); } @@ -159,25 +165,31 @@ export class SignalingManager { const credentials = this.getLastIdentify(); if (credentials) { - this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName }); + this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, + oderId: credentials.oderId, + displayName: credentials.displayName }); } const memberIds = this.getMemberServerIds(); if (memberIds.size > 0) { memberIds.forEach((serverId) => { - this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, 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 }); + 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 }); + this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, + serverId: lastJoined.serverId }); } } } diff --git a/src/app/core/services/webrtc/voice-leveling.manager.ts b/src/app/core/services/webrtc/voice-leveling.manager.ts index 0fdc69f..b920bc1 100644 --- a/src/app/core/services/webrtc/voice-leveling.manager.ts +++ b/src/app/core/services/webrtc/voice-leveling.manager.ts @@ -1,6 +1,6 @@ /* eslint-disable id-length, max-statements-per-line */ /** - * VoiceLevelingManager — manages per-speaker automatic gain control + * VoiceLevelingManager - manages per-speaker automatic gain control * pipelines for remote voice streams. * * ═══════════════════════════════════════════════════════════════════ @@ -13,9 +13,9 @@ * ↓ * MediaStreamSource (AudioContext) * ↓ - * AudioWorkletNode (VoiceLevelingProcessor — per-speaker AGC) + * AudioWorkletNode (VoiceLevelingProcessor - per-speaker AGC) * ↓ - * GainNode (post fine-tuning — master volume knob) + * GainNode (post fine-tuning - master volume knob) * ↓ * MediaStreamDestination → leveled MediaStream * @@ -26,7 +26,7 @@ * for browsers that don't support AudioWorklet or SharedArrayBuffer. * * ═══════════════════════════════════════════════════════════════════ - * DESIGN — mirrors the NoiseReductionManager pattern + * DESIGN - mirrors the NoiseReductionManager pattern * ═══════════════════════════════════════════════════════════════════ * * • `enable(peerId, rawStream)` builds the pipeline and returns a @@ -37,7 +37,7 @@ * * The calling component keeps a reference to the original raw stream * and swaps the Audio element's `srcObject` between the raw stream - * and the leveled stream when the user toggles the feature — exactly + * and the leveled stream when the user toggles the feature - exactly * like noise reduction does for the local mic. * * ═══════════════════════════════════════════════════════════════════ @@ -90,7 +90,7 @@ interface SpeakerPipeline { /** AudioWorklet module path (served from public/). */ const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js'; -/** Processor name — must match `registerProcessor` in the worklet. */ +/** Processor name - must match `registerProcessor` in the worklet. */ const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor'; /* ──────────────────────────────────────────────────────────────── */ @@ -134,7 +134,9 @@ export class VoiceLevelingManager { * Only provided keys are updated; the rest stay unchanged. */ updateSettings(partial: Partial): void { - this._settings = { ...this._settings, ...partial }; + this._settings = { ...this._settings, + ...partial }; + this.pipelines.forEach((p) => this._pushSettingsToPipeline(p)); } @@ -180,6 +182,7 @@ export class VoiceLevelingManager { peerId, fallback: pipeline.isFallback }); + return pipeline.destination.stream; } catch (err) { this.logger.error('VoiceLeveling: pipeline build failed, returning raw stream', err); diff --git a/src/app/core/services/webrtc/webrtc-logger.ts b/src/app/core/services/webrtc/webrtc-logger.ts index 09b6dc9..ba78c3d 100644 --- a/src/app/core/services/webrtc/webrtc-logger.ts +++ b/src/app/core/services/webrtc/webrtc-logger.ts @@ -46,9 +46,14 @@ export class WebRTCLogger { settings }); - track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind })); - track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id, kind: track.kind })); - track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id, kind: track.kind })); + track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, + kind: track.kind })); + + track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id, + kind: track.kind })); + + track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id, + kind: track.kind })); } /** Log a MediaStream summary and attach diagnostics to every track. */ @@ -65,8 +70,10 @@ export class WebRTCLogger { 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 8cecf3d..2b3bc56 100644 --- a/src/app/core/services/webrtc/webrtc.constants.ts +++ b/src/app/core/services/webrtc/webrtc.constants.ts @@ -30,9 +30,9 @@ export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000; /** Data channel name used for P2P chat */ export const DATA_CHANNEL_LABEL = 'chat'; -/** High-water mark (bytes) – pause sending when buffered amount exceeds this */ +/** High-water mark (bytes) - pause sending when buffered amount exceeds this */ export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB -/** Low-water mark (bytes) – resume sending once buffered amount drops below this */ +/** Low-water mark (bytes) - resume sending once buffered amount drops below this */ export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB export const SCREEN_SHARE_IDEAL_WIDTH = 1920; 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 48ce29d..d46ae76 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.html +++ b/src/app/features/admin/admin-panel/admin-panel.component.html @@ -2,7 +2,10 @@
- +

Admin Panel

@@ -17,7 +20,10 @@ [class.border-primary]="activeTab() === 'settings'" [class.text-muted-foreground]="activeTab() !== 'settings'" > - + Settings
@@ -67,7 +82,11 @@
- +
- + + + + + @if (dragActive()) { +
+
Drop files to attach
+
} - - @if (showNewMessagesBar()) { -
-
- New messages - -
+ + @if (pendingFiles.length > 0) { +
+ @for (file of pendingFiles; track file.name) { +
+
{{ file.name }}
+
{{ formatBytes(file.size) }}
+ +
+ }
}
+
+
- -
- - - @if (replyTo()) { -
- - - Replying to {{ replyTo()?.senderName }} - - -
- } - - - - - - @if (toolbarVisible()) { -
-
- - - - - | - - - - - - - - - - -
-
- } - - -
-
- - + + @if (lightboxAttachment()) { +
+
+ + +
- - @if (dragActive()) { -
-
Drop files to attach
-
- } - - @if (pendingFiles.length > 0) { -
- @for (file of pendingFiles; track file.name) { -
-
{{ file.name }}
-
{{ formatBytes(file.size) }}
- -
- } -
- } + +
+ +
+
+ {{ lightboxAttachment()!.filename }} + {{ formatBytes(lightboxAttachment()!.size) }} +
+ } - - @if (lightboxAttachment()) { -
+ @if (imageContextMenu()) { + + - -
- -
-
- {{ lightboxAttachment()!.filename }} - {{ formatBytes(lightboxAttachment()!.size) }} -
-
-
-
- } - - - @if (imageContextMenu()) { - - - - - } + + Copy Image + + + + } +
diff --git a/src/app/features/chat/chat-messages/chat-messages.component.ts b/src/app/features/chat/chat-messages/chat-messages.component.ts index 81c9630..8bf360a 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.ts +++ b/src/app/features/chat/chat-messages/chat-messages.component.ts @@ -1,5 +1,17 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-len, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */ -import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; +/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */ +import { + Component, + inject, + signal, + computed, + effect, + ElementRef, + ViewChild, + AfterViewChecked, + OnInit, + OnDestroy, + ChangeDetectorRef +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; @@ -21,7 +33,11 @@ import { } from '@ng-icons/lucide'; import { MessagesActions } from '../../../store/messages/messages.actions'; -import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors'; +import { + selectAllMessages, + selectMessagesLoading, + selectMessagesSyncing +} from '../../../store/messages/messages.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors'; import { Message } from '../../../core/models'; @@ -36,12 +52,30 @@ import remarkParse from 'remark-parse'; import { unified } from 'unified'; import { ChatMarkdownService } from './services/chat-markdown.service'; -const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀']; +const COMMON_EMOJIS = [ + '👍', + '❤️', + '😂', + '😮', + '😢', + '🎉', + '🔥', + '👀' +]; @Component({ selector: 'app-chat-messages', standalone: true, - imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent], + imports: [ + CommonModule, + FormsModule, + NgIcon, + ContextMenuComponent, + UserAvatarComponent, + TypingIndicatorComponent, + RemarkModule, + MermaidComponent + ], viewProviders: [ provideIcons({ lucideSend, @@ -105,7 +139,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro ); }); - /** Paginated view — only the most recent `displayLimit` messages */ + /** Paginated view - only the most recent `displayLimit` messages */ messages = computed(() => { const all = this.allChannelMessages(); const limit = this.displayLimit(); @@ -391,7 +425,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro const el = container.querySelector(`[data-message-id="${messageId}"]`); if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.scrollIntoView({ behavior: 'smooth', + block: 'center' }); + el.classList.add('bg-primary/10'); setTimeout(() => el.classList.remove('bg-primary/10'), 2000); } @@ -406,7 +442,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro /** Add a reaction emoji to a message. */ addReaction(messageId: string, emoji: string): void { - this.store.dispatch(MessagesActions.addReaction({ messageId, emoji })); + this.store.dispatch(MessagesActions.addReaction({ messageId, + emoji })); + this.showEmojiPicker.set(null); } @@ -423,9 +461,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro ); if (hasReacted) { - this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji })); + this.store.dispatch(MessagesActions.removeReaction({ messageId, + emoji })); } else { - this.store.dispatch(MessagesActions.addReaction({ messageId, emoji })); + this.store.dispatch(MessagesActions.addReaction({ messageId, + emoji })); } } @@ -440,7 +480,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro const currentUserId = this.currentUser()?.id; message.reactions.forEach((reaction) => { - const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false }; + const existing = groups.get(reaction.emoji) || { count: 0, + hasCurrentUser: false }; groups.set(reaction.emoji, { count: existing.count + 1, @@ -458,7 +499,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro formatTimestamp(timestamp: number): string { const date = new Date(timestamp); const now = new Date(this.nowRef); - const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const time = date.toLocaleTimeString([], { hour: '2-digit', + minute: '2-digit' }); // Compare calendar days (midnight-aligned) to avoid NG0100 flicker const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24)); @@ -470,7 +512,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro } else if (dayDiff < 7) { return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time; } else { - return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time; + return date.toLocaleDateString([], { month: 'short', + day: 'numeric' }) + ' ' + time; } } @@ -513,6 +556,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro this.initialScrollObserver = new MutationObserver(() => { requestAnimationFrame(snap); }); + this.initialScrollObserver.observe(el, { childList: true, subtree: true, @@ -550,7 +594,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro const el = this.messagesContainer.nativeElement; try { - el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + el.scrollTo({ top: el.scrollHeight, + behavior: 'smooth' }); } catch { // Fallback if smooth not supported el.scrollTop = el.scrollHeight; @@ -591,7 +636,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro this.stopInitialScrollWatch(); } - // Infinite scroll upwards — load older messages when near the top + // Infinite scroll upwards - load older messages when near the top if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) { this.loadMore(); } @@ -640,7 +685,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro private getSelection(): { start: number; end: number } { const el = this.messageInputRef?.nativeElement; - return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length }; + return { start: el?.selectionStart ?? this.messageContent.length, + end: el?.selectionEnd ?? this.messageContent.length }; } private setSelection(start: number, end: number): void { @@ -772,7 +818,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro /** Format a byte count into a human-readable size string (B, KB, MB, GB). */ formatBytes(bytes: number): string { - const units = ['B', 'KB', 'MB', 'GB']; + const units = [ + 'B', + 'KB', + 'MB', + 'GB' + ]; let size = bytes; let i = 0; @@ -787,7 +838,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro if (!bps || bps <= 0) return '0 B/s'; - const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; + const units = [ + 'B/s', + 'KB/s', + 'MB/s', + 'GB/s' + ]; let speed = bps; let i = 0; @@ -855,7 +911,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro openImageContextMenu(event: MouseEvent, att: Attachment): void { event.preventDefault(); event.stopPropagation(); - this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att }); + this.imageContextMenu.set({ x: event.clientX, + y: event.clientY, + attachment: att }); } /** Close the image context menu. */ @@ -876,9 +934,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro // Convert to PNG for clipboard compatibility const pngBlob = await this.convertToPng(blob); - await navigator.clipboard.write([ - new ClipboardItem({ 'image/png': pngBlob }) - ]); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]); } catch (_error) { // Failed to copy image to clipboard } diff --git a/src/app/features/chat/chat-messages/services/chat-markdown.service.ts b/src/app/features/chat/chat-messages/services/chat-markdown.service.ts index e036153..572d620 100644 --- a/src/app/features/chat/chat-messages/services/chat-markdown.service.ts +++ b/src/app/features/chat/chat-messages/services/chat-markdown.service.ts @@ -21,7 +21,9 @@ export class ChatMarkdownService { const newText = `${before}${token}${selected}${token}${after}`; const cursor = before.length + token.length + selected.length + token.length; - return { text: newText, selectionStart: cursor, selectionEnd: cursor }; + return { text: newText, + selectionStart: cursor, + selectionEnd: cursor }; } applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult { @@ -34,7 +36,9 @@ export class ChatMarkdownService { const text = `${before}${newSelected}${after}`; const cursor = before.length + newSelected.length; - return { text, selectionStart: cursor, selectionEnd: cursor }; + return { text, + selectionStart: cursor, + selectionEnd: cursor }; } applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult { @@ -49,7 +53,9 @@ export class ChatMarkdownService { const text = `${before}${block}${after}`; const cursor = before.length + block.length; - return { text, selectionStart: cursor, selectionEnd: cursor }; + return { text, + selectionStart: cursor, + selectionEnd: cursor }; } applyOrderedList(content: string, selection: SelectionRange): ComposeResult { @@ -62,7 +68,9 @@ export class ChatMarkdownService { const text = `${before}${newSelected}${after}`; const cursor = before.length + newSelected.length; - return { text, selectionStart: cursor, selectionEnd: cursor }; + return { text, + selectionStart: cursor, + selectionEnd: cursor }; } applyCodeBlock(content: string, selection: SelectionRange): ComposeResult { @@ -74,7 +82,9 @@ export class ChatMarkdownService { const text = `${before}${fenced}${after}`; const cursor = before.length + fenced.length; - return { text, selectionStart: cursor, selectionEnd: cursor }; + return { text, + selectionStart: cursor, + selectionEnd: cursor }; } applyLink(content: string, selection: SelectionRange): ComposeResult { @@ -87,7 +97,9 @@ export class ChatMarkdownService { const cursorStart = before.length + link.length - 1; // Position inside the URL placeholder - return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 }; + return { text, + selectionStart: cursorStart - 8, + selectionEnd: cursorStart - 1 }; } applyImage(content: string, selection: SelectionRange): ComposeResult { @@ -99,7 +111,9 @@ export class ChatMarkdownService { const text = `${before}${img}${after}`; const cursorStart = before.length + img.length - 1; - return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 }; + return { text, + selectionStart: cursorStart - 8, + selectionEnd: cursorStart - 1 }; } applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult { @@ -110,7 +124,9 @@ export class ChatMarkdownService { const text = `${before}${hr}${after}`; const cursor = before.length + hr.length; - return { text, selectionStart: cursor, selectionEnd: cursor }; + return { text, + selectionStart: cursor, + selectionEnd: cursor }; } appendImageMarkdown(content: string): string { diff --git a/src/app/features/chat/typing-indicator/typing-indicator.component.ts b/src/app/features/chat/typing-indicator/typing-indicator.component.ts index ccb0e85..a5f1084 100644 --- a/src/app/features/chat/typing-indicator/typing-indicator.component.ts +++ b/src/app/features/chat/typing-indicator/typing-indicator.component.ts @@ -1,8 +1,19 @@ /* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */ -import { Component, inject, signal, DestroyRef } from '@angular/core'; +import { + Component, + inject, + signal, + DestroyRef +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { WebRTCService } from '../../../core/services/webrtc.service'; -import { merge, interval, filter, map, tap } from 'rxjs'; +import { + merge, + interval, + filter, + map, + tap +} from 'rxjs'; const TYPING_TTL = 3_000; const PURGE_INTERVAL = 1_000; diff --git a/src/app/features/chat/user-list/user-list.component.html b/src/app/features/chat/user-list/user-list.component.html index 14e6c55..222a55a 100644 --- a/src/app/features/chat/user-list/user-list.component.html +++ b/src/app/features/chat/user-list/user-list.component.html @@ -1,161 +1,202 @@ - -
-

Members

-

{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice

- @if (voiceUsers().length > 0) { -
- @for (v of voiceUsers(); track v.id) { - - - {{ v.displayName }} - - } -
- } + +
+

Members

+

{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice

+ @if (voiceUsers().length > 0) { +
+ @for (v of voiceUsers(); track v.id) { + + + {{ v.displayName }} + + } +
+ } +
+ + +
+ @for (user of onlineUsers(); track user.id) { +
+ +
+ +
- -
- @for (user of onlineUsers(); track user.id) { -
- -
- - -
- - -
-
- - {{ user.displayName }} - - @if (user.isAdmin) { - - } - @if (user.isRoomOwner) { - - } -
-
- - -
- @if (user.voiceState?.isSpeaking) { - - } @else if (user.voiceState?.isMuted) { - - } @else if (user.voiceState?.isConnected) { - - } - - @if (user.screenShareState?.isSharing) { - - } -
- - - @if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) { - - } -
- } - - @if (onlineUsers().length === 0) { -
- No users online -
- } + +
+
+ + {{ user.displayName }} + + @if (user.isAdmin) { + + } + @if (user.isRoomOwner) { + + } +
- - @if (showBanDialog()) { - -

- Are you sure you want to ban {{ userToBan()?.displayName }}? -

- -
- - +
+ @if (user.voiceState?.isSpeaking) { + -
+ } @else if (user.voiceState?.isMuted) { + + } @else if (user.voiceState?.isConnected) { + + } -
- - + + Kick + +
- - } + } +
+ } + + @if (onlineUsers().length === 0) { +
No users online
+ } +
+ + +@if (showBanDialog()) { + +

+ Are you sure you want to ban {{ userToBan()?.displayName }}? +

+ +
+ + +
+ +
+ + +
+
+} diff --git a/src/app/features/chat/user-list/user-list.component.ts b/src/app/features/chat/user-list/user-list.component.ts index 509f8f2..0644807 100644 --- a/src/app/features/chat/user-list/user-list.component.ts +++ b/src/app/features/chat/user-list/user-list.component.ts @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/member-ordering */ -import { Component, inject, signal, computed } from '@angular/core'; +import { + Component, + inject, + signal, + computed +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; @@ -29,7 +34,13 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; @Component({ selector: 'app-user-list', standalone: true, - imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent], + imports: [ + CommonModule, + FormsModule, + NgIcon, + UserAvatarComponent, + ConfirmDialogComponent + ], viewProviders: [ provideIcons({ lucideMic, diff --git a/src/app/features/room/chat-room/chat-room.component.html b/src/app/features/room/chat-room/chat-room.component.html index 6ea7bc6..f83256c 100644 --- a/src/app/features/room/chat-room/chat-room.component.html +++ b/src/app/features/room/chat-room/chat-room.component.html @@ -29,7 +29,10 @@
- +

No room selected

Select or create a room to start chatting

diff --git a/src/app/features/room/chat-room/chat-room.component.ts b/src/app/features/room/chat-room/chat-room.component.ts index 42cee8e..106a681 100644 --- a/src/app/features/room/chat-room/chat-room.component.ts +++ b/src/app/features/room/chat-room/chat-room.component.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/member-ordering */ -import { Component, inject, signal } from '@angular/core'; +import { + Component, + inject, + signal +} from '@angular/core'; import { Router } from '@angular/router'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 2099525..ef8df8e 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -13,7 +13,10 @@ [class.text-muted-foreground]="activeTab() !== 'channels'" [class.hover:text-foreground]="activeTab() !== 'channels'" > - + Channels
@@ -40,16 +44,17 @@
-

- Text Channels -

+

Text Channels

@if (canManageChannels()) { }
@@ -89,16 +94,17 @@
-

- Voice Channels -

+

Voice Channels

@if (canManageChannels()) { }
@@ -116,7 +122,10 @@ [disabled]="!voiceEnabled()" > - + @if (renamingChannelId() === ch.id) { - {{ - u.displayName - }} + {{ u.displayName }} @if (u.id !== currentUser()?.id) { } @if (u.screenShareState?.isSharing || isUserSharing(u.id)) { @@ -177,7 +182,10 @@ } @if (u.voiceState?.isMuted) { - + }
} @@ -196,9 +204,7 @@ @if (currentUser()) {
-

- You -

+

You

- +

{{ currentUser()?.displayName }}

@if (currentUser()?.voiceState?.isConnected) {

- + In voice

} - @if ( - currentUser()?.screenShareState?.isSharing || - (currentUser()?.id && isUserSharing(currentUser()!.id)) - ) { - - + @if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) { + + LIVE } @@ -239,9 +244,7 @@ @if (onlineUsersFiltered().length > 0) {
-

- Online — {{ onlineUsersFiltered().length }} -

+

Online - {{ onlineUsersFiltered().length }}

@for (user of onlineUsersFiltered(); track user.id) {
- +

{{ user.displayName }}

@if (user.role === 'host') { - Owner + Owner } @else if (user.role === 'admin') { - Admin + Admin } @else if (user.role === 'moderator') { - Mod + Mod }
@if (user.voiceState?.isConnected) {

- + In voice

} @@ -290,7 +285,10 @@ (click)="viewStream(user.id); $event.stopPropagation()" class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse" > - + LIVE } @@ -327,42 +325,81 @@ (closed)="closeChannelMenu()" [width]="'w-44'" > - + @if (canManageChannels()) {
- - + + } } @if (showUserMenu()) { - + @if (isAdmin()) { @if (contextMenuUser()?.role === 'member') { - - } @if (contextMenuUser()?.role === 'moderator') { - - } @if (contextMenuUser()?.role === 'admin') { - }
- + } @else {
No actions available
} diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index 34a4321..9ba3e83 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ -import { Component, inject, signal } from '@angular/core'; +import { + Component, + inject, + signal +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; @@ -32,8 +36,17 @@ import { WebRTCService } from '../../../core/services/webrtc.service'; import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { VoiceActivityService } from '../../../core/services/voice-activity.service'; import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; -import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; -import { Channel, User } from '../../../core/models'; +import { + ContextMenuComponent, + UserAvatarComponent, + ConfirmDialogComponent +} from '../../../shared'; +import { + Channel, + ChatEvent, + Room, + User +} from '../../../core/models'; import { v4 as uuidv4 } from 'uuid'; type TabView = 'channels' | 'users'; @@ -41,15 +54,7 @@ type TabView = 'channels' | 'users'; @Component({ selector: 'app-rooms-side-panel', standalone: true, - imports: [ - CommonModule, - FormsModule, - NgIcon, - VoiceControlsComponent, - ContextMenuComponent, - UserAvatarComponent, - ConfirmDialogComponent - ], + imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent], viewProviders: [ provideIcons({ lucideMessageSquare, @@ -110,9 +115,7 @@ export class RoomsSidePanelComponent { const currentId = current?.id; const currentOderId = current?.oderId; - return this.onlineUsers().filter( - (user) => user.id !== currentId && user.oderId !== currentOderId - ); + return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId); } /** Check whether the current user has permission to manage channels. */ @@ -218,12 +221,14 @@ export class RoomsSidePanelComponent { const peers = this.webrtc.getConnectedPeers(); if (peers.length === 0) { - // No connected peers — sync will time out + // No connected peers - sync will time out } + const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id }; + peers.forEach((pid) => { try { - this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any); + this.webrtc.sendToPeer(pid, inventoryRequest); } catch (_error) { // Failed to send inventory request to this peer } @@ -327,6 +332,9 @@ export class RoomsSidePanelComponent { return; } + if (!room) + return; + const current = this.currentUser(); // Check if already connected to voice in a DIFFERENT server - must disconnect first @@ -334,7 +342,7 @@ export class RoomsSidePanelComponent { // clear it so the user can join. if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { if (!this.webrtc.isVoiceConnected()) { - // Stale state – clear it so the user can proceed + // Stale state - clear it so the user can proceed if (current.id) { this.store.dispatch( UsersActions.updateVoiceState({ @@ -356,67 +364,76 @@ export class RoomsSidePanelComponent { } // If switching channels within the same server, just update the room - const isSwitchingChannels = - current?.voiceState?.isConnected && - current.voiceState.serverId === room?.id && - current.voiceState.roomId !== roomId; + const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId; // Enable microphone and broadcast voice-state const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); enableVoicePromise - .then(() => { - if (current?.id && room) { - this.store.dispatch( - UsersActions.updateVoiceState({ - userId: current.id, - voiceState: { - isConnected: true, - isMuted: current.voiceState?.isMuted ?? false, - isDeafened: current.voiceState?.isDeafened ?? false, - roomId: roomId, - serverId: room.id - } - }) - ); - } - - // Start voice heartbeat to broadcast presence every 5 seconds - this.webrtc.startVoiceHeartbeat(roomId, room?.id); - this.webrtc.broadcastMessage({ - type: 'voice-state', - oderId: current?.oderId || current?.id, - displayName: current?.displayName || 'User', - voiceState: { - isConnected: true, - isMuted: current?.voiceState?.isMuted ?? false, - isDeafened: current?.voiceState?.isDeafened ?? false, - roomId: roomId, - serverId: room?.id - } - }); - - // Update voice session for floating controls - if (room) { - // Find label from channel list - const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId); - const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId; - - this.voiceSessionService.startSession({ - serverId: room.id, - serverName: room.name, - roomId: roomId, - roomName: voiceRoomName, - serverIcon: room.icon, - serverDescription: room.description, - serverRoute: `/room/${room.id}` - }); - } - }) + .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null)) .catch((_error) => { // Failed to join voice room }); } + private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void { + this.updateVoiceStateStore(roomId, room, current); + this.startVoiceHeartbeat(roomId, room); + this.broadcastVoiceConnected(roomId, room, current); + this.startVoiceSession(roomId, room); + } + + private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void { + if (!current?.id) + return; + + this.store.dispatch( + UsersActions.updateVoiceState({ + userId: current.id, + voiceState: { + isConnected: true, + isMuted: current.voiceState?.isMuted ?? false, + isDeafened: current.voiceState?.isDeafened ?? false, + roomId, + serverId: room.id + } + }) + ); + } + + private startVoiceHeartbeat(roomId: string, room: Room): void { + this.webrtc.startVoiceHeartbeat(roomId, room.id); + } + + private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void { + this.webrtc.broadcastMessage({ + type: 'voice-state', + oderId: current?.oderId || current?.id, + displayName: current?.displayName || 'User', + voiceState: { + isConnected: true, + isMuted: current?.voiceState?.isMuted ?? false, + isDeafened: current?.voiceState?.isDeafened ?? false, + roomId, + serverId: room.id + } + }); + } + + private startVoiceSession(roomId: string, room: Room): void { + const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId); + const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId; + + this.voiceSessionService.startSession({ + serverId: room.id, + serverName: room.name, + roomId, + roomName: voiceRoomName, + serverIcon: room.icon, + serverDescription: room.description, + serverRoute: `/room/${room.id}` + }); + } + /** Leave a voice channel and broadcast the disconnect state. */ leaveVoice(roomId: string) { const current = this.currentUser(); @@ -470,12 +487,8 @@ export class RoomsSidePanelComponent { const users = this.onlineUsers(); const room = this.currentRoom(); - return users.filter( - (user) => - !!user.voiceState?.isConnected && - user.voiceState?.roomId === roomId && - user.voiceState?.serverId === room?.id - ).length; + return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id) + .length; } /** Dispatch a viewer:focus event to display a remote user's screen share. */ @@ -500,9 +513,7 @@ export class RoomsSidePanelComponent { return this.webrtc.isScreenSharing(); } - const user = this.onlineUsers().find( - (onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId - ); + const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId); if (user?.screenShareState?.isSharing === false) { return false; @@ -518,10 +529,7 @@ export class RoomsSidePanelComponent { const room = this.currentRoom(); return this.onlineUsers().filter( - (user) => - !!user.voiceState?.isConnected && - user.voiceState?.roomId === roomId && - user.voiceState?.serverId === room?.id + (user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id ); } @@ -530,11 +538,7 @@ export class RoomsSidePanelComponent { const me = this.currentUser(); const room = this.currentRoom(); - return !!( - me?.voiceState?.isConnected && - me.voiceState?.roomId === roomId && - me.voiceState?.serverId === room?.id - ); + return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id); } /** Check whether voice is enabled by the current room's permissions. */ @@ -558,8 +562,8 @@ export class RoomsSidePanelComponent { /** * Return a Tailwind `bg-*` class representing the latency quality. * - green : < 100 ms - * - yellow : 100–199 ms - * - orange : 200–349 ms + * - yellow : 100-199 ms + * - orange : 200-349 ms * - red : >= 350 ms * - gray : no data yet */ diff --git a/src/app/features/server-search/server-search.component.html b/src/app/features/server-search/server-search.component.html index 4cf2168..e16c3ae 100644 --- a/src/app/features/server-search/server-search.component.html +++ b/src/app/features/server-search/server-search.component.html @@ -40,7 +40,10 @@ class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors" title="Settings" > - +
@@ -52,7 +55,10 @@ type="button" class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors" > - + Create New Server
@@ -65,7 +71,10 @@
} @else if (searchResults().length === 0) {
- +

No servers found

Try a different search or create your own

@@ -84,9 +93,15 @@ {{ server.name }} @if (server.isPrivate) { - + } @else { - + }
@if (server.description) { @@ -101,13 +116,14 @@ }
- + {{ server.userCount }}/{{ server.maxUsers }}
-
- Hosted by {{ server.hostName }} -
+
Hosted by {{ server.hostName }}
}
@@ -145,7 +161,11 @@
- +
- +