Now formatted correctly with eslint

This commit is contained in:
2026-03-04 00:41:02 +01:00
parent ad0e28bf84
commit 4e95ae77c5
99 changed files with 3231 additions and 1464 deletions

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
dist/
release/
node_modules/
**/migrations/**
**/generated/**

14
.prettierrc.json Normal file
View File

@@ -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"
}

View File

@@ -7,8 +7,11 @@
} }
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", // "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
}, },
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
@@ -23,5 +26,5 @@
"prettier.printWidth": 150, "prettier.printWidth": 150,
"prettier.singleAttributePerLine": true, "prettier.singleAttributePerLine": true,
"prettier.htmlWhitespaceSensitivity": "css", "prettier.htmlWhitespaceSensitivity": "css",
"prettier.tabWidth": 4 "prettier.tabWidth": 2
} }

62
.vscode/tasks.json vendored
View File

@@ -1,41 +1,39 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"type": "npm", "label": "Sort Template Properties",
"script": "start", "type": "shell",
"isBackground": true, "command": "node",
"problemMatcher": { "args": [
"owner": "typescript", "tools/sort-template-properties.js",
"pattern": "$tsc", "${file}"
"background": { ],
"activeOnStart": true, "presentation": {
"beginsPattern": { "reveal": "silent",
"regexp": "Changes detected" "panel": "shared"
}, },
"endsPattern": { "runOptions": {
"regexp": "bundle generation (complete|failed)" "runOn": "folderOpen"
} },
} "problemMatcher": []
}
}, },
{ {
"type": "npm", "label": "Format HTML on Save",
"script": "test", "type": "shell",
"isBackground": true, "command": "npx",
"problemMatcher": { "args": [
"owner": "typescript", "prettier",
"pattern": "$tsc", "--write",
"background": { "${file}"
"activeOnStart": true, ],
"beginsPattern": { "presentation": {
"regexp": "Changes detected" "reveal": "silent",
}, "panel": "shared"
"endsPattern": { },
"regexp": "bundle generation (complete|failed)" "problemMatcher": [],
} "runOptions": {
} "runOn": "folderOpen"
} }
} }
] ]

View File

@@ -3,7 +3,10 @@
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm", "packageManager": "npm",
"analytics": false "analytics": false,
"schematicCollections": [
"angular-eslint"
]
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
@@ -100,6 +103,15 @@
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
} }
} }
} }

View File

@@ -1,39 +1,57 @@
// ESLint Flat Config for Weaver
const eslint = require('@eslint/js'); const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint'); const tseslint = require('typescript-eslint');
const angular = require('angular-eslint'); const angular = require('angular-eslint');
const stylisticTs = require('@stylistic/eslint-plugin-ts'); const stylisticTs = require('@stylistic/eslint-plugin-ts');
const stylisticJs = require('@stylistic/eslint-plugin-js'); 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( module.exports = tseslint.config(
{ {
ignores: [ ignores: ['**/generated/*','dist/**', '**/migrations/**', 'release/**']
'**/generated/*',
'dist/**',
'dist-electron/**',
'.angular/**',
'**/migrations/**',
'release/**',
'src/index.html',
'server/**'
]
},
{
files: ['src/app/core/services/**/*.ts'],
rules: {
'@typescript-eslint/member-ordering': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-invalid-void-type': 'off',
'@typescript-eslint/prefer-for-of': 'off',
'id-length': 'off',
'max-statements-per-line': 'off'
}
}, },
{ {
files: ['**/*.ts'], files: ['**/*.ts'],
plugins: { plugins: {
'@stylistic/ts': stylisticTs, '@stylistic/ts': stylisticTs,
'@stylistic/js': stylisticJs '@stylistic/js': stylisticJs,
'import-newlines': newlines,
'no-dashes': noDashPlugin
}, },
extends: [ extends: [
eslint.configs.recommended, eslint.configs.recommended,
@@ -44,92 +62,38 @@ module.exports = tseslint.config(
], ],
processor: angular.processInlineTemplates, processor: angular.processInlineTemplates,
rules: { rules: {
'no-dashes/no-unicode-dashes': 'error',
'@typescript-eslint/no-extraneous-class': 'off', '@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', '@angular-eslint/directive-class-suffix': 'error',
'@typescript-eslint/explicit-module-boundry-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }], '@typescript-eslint/explicit-member-accessibility': ['error',{ accessibility: 'no-public' }],
'@typescript-eslint/array-type': ['error', { default: 'array' }], '@typescript-eslint/array-type': ['error',{ default: 'array' }],
'@typescript-eslint/consistent-type-definitions': 'error', '@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/dot-notation': 'off', '@typescript-eslint/dot-notation': 'off',
'@stylistic/ts/indent': [ '@stylistic/ts/indent': ['error',2,{ ignoredNodes:[
'error', 'TSTypeParameterInstantation',
2, 'FunctionExpression > .params[decorators.length > 0]',
{ 'FunctionExpression > .params > :matches(Decorator, :not(:first-child))',
ignoredNodes: [ 'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
'TSTypeParameterInstantiation', ], SwitchCase:1 }],
'FunctionExpression > .params[decorators.length > 0]', '@stylistic/ts/member-delimiter-style': ['error',{ multiline:{ delimiter:'semi', requireLast:true }, singleline:{ delimiter:'semi', requireLast:false } }],
'FunctionExpression > .params > :matches(Decorator, :not(:first-child))', '@typescript-eslint/member-ordering': ['error',{ default:[
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key' 'signature','call-signature',
], 'public-static-field','protected-static-field','private-static-field','#private-static-field',
SwitchCase: 1 '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',
'@stylistic/ts/member-delimiter-style': [ 'public-field','protected-field','private-field','#private-field',
'error', 'static-field','instance-field','abstract-field','decorated-field','field','static-initialization',
{ 'public-constructor','protected-constructor','private-constructor','constructor',
multiline: { delimiter: 'semi', requireLast: true }, 'public-static-method','protected-static-method','private-static-method','#private-static-method',
singleline: { delimiter: 'semi', requireLast: false } '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',
'@typescript-eslint/member-ordering': [ 'static-method','instance-method','abstract-method','decorated-method','method'
'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-function': 'off',
'@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
@@ -137,23 +101,23 @@ module.exports = tseslint.config(
'@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/prefer-namespace-keyword': 'error', '@typescript-eslint/prefer-namespace-keyword': 'error',
'@typescript-eslint/no-unused-expressions': '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/no-var-requires': 'error',
'@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-function-type': 'error',
'@stylistic/ts/quotes': ['error', 'single', { avoidEscape: true }], '@stylistic/ts/quotes': ['error','single',{ avoidEscape:true }],
'@stylistic/ts/semi': ['error', 'always'], '@stylistic/ts/semi': ['error','always'],
'@stylistic/ts/type-annotation-spacing': 'error', '@stylistic/ts/type-annotation-spacing': 'error',
'@typescript-eslint/unified-signatures': 'error', '@typescript-eslint/unified-signatures': 'error',
'@stylistic/js/array-bracket-spacing': '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/ts/comma-spacing': 'error',
'@stylistic/js/comma-style': 'error', '@stylistic/js/comma-style': 'error',
complexity: ['warn', { max: 20 }], 'complexity': ['warn',{ max:20 }],
curly: 'off', 'curly': 'off',
'eol-last': 'error', 'eol-last': 'error',
'id-denylist': ['warn', 'e', 'cb', 'i', 'x', 'c', 'y', 'any', 'string', 'String', 'Undefined', 'undefined', 'callback'], 'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
'max-len': ['error', { code: 150, ignoreComments: true }], 'max-len': ['error',{ code:150, ignoreComments:true }],
'new-parens': 'error', 'new-parens': 'error',
'newline-per-chained-call': 'error', 'newline-per-chained-call': 'error',
'no-bitwise': 'off', 'no-bitwise': 'off',
@@ -161,47 +125,70 @@ module.exports = tseslint.config(
'no-empty': 'off', 'no-empty': 'off',
'no-eval': 'error', 'no-eval': 'error',
'@stylistic/js/no-multi-spaces': '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-new-wrappers': 'error',
'no-restricted-imports': ['error', 'rxjs/Rx'], 'no-restricted-imports': ['error','rxjs/Rx'],
'no-throw-literal': 'error', 'no-throw-literal': 'error',
'no-trailing-spaces': 'error', 'no-trailing-spaces': 'error',
'no-undef-init': 'error', 'no-undef-init': 'error',
'no-unsafe-finally': 'error', 'no-unsafe-finally': 'error',
'no-var': 'error', 'no-var': 'error',
'one-var': ['error', 'never'], 'one-var': ['error','never'],
'prefer-const': 'error', 'prefer-const': 'error',
'@stylistic/ts/space-before-blocks': '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/ts/space-infix-ops': 'error',
'@stylistic/js/space-in-parens': 'error', '@stylistic/js/space-in-parens': 'error',
'@stylistic/js/space-unary-ops': 'error', '@stylistic/js/space-unary-ops': 'error',
'@stylistic/js/spaced-comment': ['error', 'always', { markers: ['/'] }], '@stylistic/js/spaced-comment': ['error','always',{ markers:['/'] }],
'@stylistic/js/block-spacing': ['error', 'always'], "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'], '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 }], '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: ['_'] }], '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': [ 'padding-line-between-statements': [
'error', 'error',
// Ensure blank lines around standalone if statements within the same scope
{ blankLine: 'always', prev: '*', next: 'if' }, { blankLine: 'always', prev: '*', next: 'if' },
{ blankLine: 'always', prev: 'if', next: '*' }, { 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: '*', next: 'block-like' },
{ blankLine: 'always', prev: 'block-like', next: '*' }, { blankLine: 'always', prev: 'block-like', next: '*' },
{ blankLine: 'always', prev: 'function', next: '*' }, // Always require a blank line after functions (and multiline expressions)
{ blankLine: 'always', prev: 'class', next: '*' }, { 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: 'const', next: '*' },
{ blankLine: 'always', prev: 'let', next: '*' }, { blankLine: 'always', prev: 'let', next: '*' },
{ blankLine: 'always', prev: 'var', 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: 'const', next: 'const' },
{ blankLine: 'never', prev: 'let', next: 'let' }, { blankLine: 'never', prev: 'let', next: 'let' },
{ blankLine: 'never', prev: 'var', next: 'var' } { blankLine: 'never', prev: 'var', next: 'var' }
] ]
} }
}, },
// HTML template formatting rules (external Angular templates only)
{ {
files: ['src/app/**/*.html'], files: ['src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: { rules: {
'no-dashes/no-unicode-dashes': 'error',
// Angular template best practices
'@angular-eslint/template/button-has-type': 'warn', '@angular-eslint/template/button-has-type': 'warn',
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }], '@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
'@angular-eslint/template/eqeqeq': 'error', '@angular-eslint/template/eqeqeq': 'error',
@@ -210,9 +197,13 @@ module.exports = tseslint.config(
'@angular-eslint/template/prefer-self-closing-tags': 'warn', '@angular-eslint/template/prefer-self-closing-tags': 'warn',
'@angular-eslint/template/use-track-by-function': 'warn', '@angular-eslint/template/use-track-by-function': 'warn',
'@angular-eslint/template/no-negated-async': '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

View File

@@ -29,19 +29,11 @@
"build:prod:all": "npm run build:prod && cd server && npm run build", "build:prod:all": "npm run build:prod && cd server && npm run build",
"build:prod:win": "npm run build:prod:all && electron-builder --win", "build:prod:win": "npm run build:prod:all && electron-builder --win",
"dev": "npm run electron:full", "dev": "npm run electron:full",
"lint": "eslint . --ext .ts,.html" "lint": "eslint .",
}, "lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"prettier": { "format": "prettier --write \"src/app/**/*.html\"",
"printWidth": 100, "format:check": "prettier --check \"src/app/**/*.html\"",
"singleQuote": true, "sort:props": "node tools/sort-template-properties.js"
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}, },
"private": true, "private": true,
"packageManager": "npm@10.9.2", "packageManager": "npm@10.9.2",
@@ -84,17 +76,22 @@
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "^21.2.0", "angular-eslint": "21.2.0",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"eslint": "^9.39.3", "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", "postcss": "^8.5.6",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "^8.56.1", "typescript-eslint": "8.50.1",
"wait-on": "^7.2.0" "wait-on": "^7.2.0"
}, },
"build": { "build": {

Binary file not shown.

View File

@@ -1,4 +1,8 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core'; import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
isDevMode
} from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideStore } from '@ngrx/store'; import { provideStore } from '@ngrx/store';
@@ -26,7 +30,12 @@ export const appConfig: ApplicationConfig = {
users: usersReducer, users: usersReducer,
rooms: roomsReducer rooms: roomsReducer
}), }),
provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]), provideEffects([
MessagesEffects,
MessagesSyncEffects,
UsersEffects,
RoomsEffects
]),
provideStoreDevtools({ provideStoreDevtools({
maxAge: STORE_DEVTOOLS_MAX_AGE, maxAge: STORE_DEVTOOLS_MAX_AGE,
logOnly: !isDevMode(), logOnly: !isDevMode(),

View File

@@ -1,6 +1,15 @@
/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */ /* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */
import { Component, OnInit, inject, HostListener } from '@angular/core'; import {
import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; Component,
OnInit,
inject,
HostListener
} from '@angular/core';
import {
Router,
RouterOutlet,
NavigationEnd
} from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';

View File

@@ -29,7 +29,7 @@ export const DEFAULT_MAX_USERS = 50;
/** Default audio bitrate in kbps for voice chat. */ /** Default audio bitrate in kbps for voice chat. */
export const DEFAULT_AUDIO_BITRATE_KBPS = 96; export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
/** Default volume level (0100). */ /** Default volume level (0-100). */
export const DEFAULT_VOLUME = 100; export const DEFAULT_VOLUME = 100;
/** Default search debounce time in milliseconds. */ /** Default search debounce time in milliseconds. */

View File

@@ -306,6 +306,7 @@ export type ChatEventType =
| 'room-deleted' | 'room-deleted'
| 'room-settings-update' | 'room-settings-update'
| 'voice-state' | 'voice-state'
| 'chat-inventory-request'
| 'voice-state-request' | 'voice-state-request'
| 'state-request' | 'state-request'
| 'screen-state' | 'screen-state'

View File

@@ -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 */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
import { Injectable, inject, signal, effect } from '@angular/core'; import {
Injectable,
inject,
signal,
effect
} from '@angular/core';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { WebRTCService } from './webrtc.service'; import { WebRTCService } from './webrtc.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -168,7 +173,7 @@ export class AttachmentService {
/** /**
* Register attachment metadata received via message sync * 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. * @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
*/ */
@@ -184,7 +189,9 @@ export class AttachmentService {
const alreadyKnown = existing.find((entry) => entry.id === meta.id); const alreadyKnown = existing.find((entry) => entry.id === meta.id);
if (!alreadyKnown) { if (!alreadyKnown) {
const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 }; const attachment: Attachment = { ...meta,
available: false,
receivedBytes: 0 };
existing.push(attachment); existing.push(attachment);
newAttachments.push(attachment); newAttachments.push(attachment);
@@ -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 { handleFileNotFound(payload: any): void {
const { messageId, fileId } = payload; const { messageId, fileId } = payload;
@@ -428,6 +435,7 @@ export class AttachmentService {
attachment.speedBps = attachment.speedBps =
EWMA_PREVIOUS_WEIGHT * previousSpeed + EWMA_PREVIOUS_WEIGHT * previousSpeed +
EWMA_CURRENT_WEIGHT * instantaneousBps; EWMA_CURRENT_WEIGHT * instantaneousBps;
attachment.lastUpdateMs = now; attachment.lastUpdateMs = now;
this.touch(); // trigger UI update for progress bars 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. * cancellation so the streaming loop breaks early.
*/ */
handleFileCancel(payload: any): void { handleFileCancel(payload: any): void {
@@ -690,6 +698,7 @@ export class AttachmentService {
messageId, messageId,
fileId fileId
} as any); } as any);
return true; return true;
} }
@@ -923,7 +932,8 @@ export class AttachmentService {
const grouped = new Map<string, Attachment[]>(); const grouped = new Map<string, Attachment[]>();
for (const record of allRecords) { for (const record of allRecords) {
const attachment: Attachment = { ...record, available: false }; const attachment: Attachment = { ...record,
available: false };
const bucket = grouped.get(record.messageId) ?? []; const bucket = grouped.get(record.messageId) ?? [];
bucket.push(attachment); bucket.push(attachment);
@@ -949,7 +959,8 @@ export class AttachmentService {
const existing = this.attachmentsByMessage.get(meta.messageId) ?? []; const existing = this.attachmentsByMessage.get(meta.messageId) ?? [];
if (!existing.find((entry) => entry.id === meta.id)) { if (!existing.find((entry) => entry.id === meta.id)) {
const attachment: Attachment = { ...meta, available: false }; const attachment: Attachment = { ...meta,
available: false };
existing.push(attachment); existing.push(attachment);
this.attachmentsByMessage.set(meta.messageId, existing); this.attachmentsByMessage.set(meta.messageId, existing);

View File

@@ -1,10 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models'; import {
Message,
User,
Room,
Reaction,
BanEntry
} from '../models';
/** IndexedDB database name for the MetoYou application. */ /** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou'; const DATABASE_NAME = 'metoyou';
/** IndexedDB schema version bump when adding/changing object stores. */ /** IndexedDB schema version - bump when adding/changing object stores. */
const DATABASE_VERSION = 2; const DATABASE_VERSION = 2;
/** Names of every object store used by the application. */ /** Names of every object store used by the application. */
const STORE_MESSAGES = 'messages'; const STORE_MESSAGES = 'messages';
@@ -77,7 +83,8 @@ export class BrowserDatabaseService {
const existing = await this.get<Message>(STORE_MESSAGES, messageId); const existing = await this.get<Message>(STORE_MESSAGES, messageId);
if (existing) { if (existing) {
await this.put(STORE_MESSAGES, { ...existing, ...updates }); await this.put(STORE_MESSAGES, { ...existing,
...updates });
} }
} }
@@ -160,7 +167,8 @@ export class BrowserDatabaseService {
/** Store which user ID is considered "current" (logged-in). */ /** Store which user ID is considered "current" (logged-in). */
async setCurrentUserId(userId: string): Promise<void> { async setCurrentUserId(userId: string): Promise<void> {
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<User>(STORE_USERS, userId); const existing = await this.get<User>(STORE_USERS, userId);
if (existing) { if (existing) {
await this.put(STORE_USERS, { ...existing, ...updates }); await this.put(STORE_USERS, { ...existing,
...updates });
} }
} }
@@ -206,7 +215,8 @@ export class BrowserDatabaseService {
const existing = await this.get<Room>(STORE_ROOMS, roomId); const existing = await this.get<Room>(STORE_ROOMS, roomId);
if (existing) { if (existing) {
await this.put(STORE_ROOMS, { ...existing, ...updates }); await this.put(STORE_ROOMS, { ...existing,
...updates });
} }
} }
@@ -296,7 +306,7 @@ export class BrowserDatabaseService {
} }
// ══════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════
// Private helpers thin wrappers around IndexedDB // Private helpers - thin wrappers around IndexedDB
// ══════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════
/** /**

View File

@@ -1,6 +1,16 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
import { inject, Injectable, signal } from '@angular/core'; import {
import { Message, User, Room, Reaction, BanEntry } from '../models'; inject,
Injectable,
signal
} from '@angular/core';
import {
Message,
User,
Room,
Reaction,
BanEntry
} from '../models';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { BrowserDatabaseService } from './browser-database.service'; import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-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). * - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}. * - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
* *
* All consumers inject `DatabaseService` the underlying storage engine * All consumers inject `DatabaseService` - the underlying storage engine
* is selected automatically. * is selected automatically.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -1,5 +1,11 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models'; import {
Message,
User,
Room,
Reaction,
BanEntry
} from '../models';
/** /**
* Database service for the Electron (desktop) runtime. * Database service for the Electron (desktop) runtime.

View File

@@ -18,7 +18,7 @@ const AUDIO_BASE = '/assets/audio';
const AUDIO_EXT = 'wav'; const AUDIO_EXT = 'wav';
/** localStorage key for persisting notification volume. */ /** localStorage key for persisting notification volume. */
const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume'; const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume';
/** Default notification volume (0 1). */ /** Default notification volume (0 - 1). */
const DEFAULT_VOLUME = 0.2; const DEFAULT_VOLUME = 0.2;
/** /**
@@ -36,7 +36,7 @@ export class NotificationAudioService {
/** Pre-loaded audio buffers keyed by {@link AppSound}. */ /** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>(); private readonly cache = new Map<AppSound, HTMLAudioElement>();
/** Reactive notification volume (0 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
constructor() { constructor() {
@@ -88,10 +88,10 @@ export class NotificationAudioService {
* Play a sound effect at the current notification volume. * Play a sound effect at the current notification volume.
* *
* If playback fails (e.g. browser autoplay policy) the error is * 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 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. * the persisted {@link notificationVolume} is used.
*/ */
play(sound: AppSound, volumeOverride?: number): void { play(sound: AppSound, volumeOverride?: number): void {

View File

@@ -15,6 +15,7 @@ export class PlatformService {
constructor() { constructor() {
this.isElectron = this.isElectron =
typeof window !== 'undefined' && !!(window as any).electronAPI; typeof window !== 'undefined' && !!(window as any).electronAPI;
this.isBrowser = !this.isElectron; this.isBrowser = !this.isElectron;
} }
} }

View File

@@ -1,9 +1,22 @@
/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */ /* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */
import { Injectable, signal, computed } from '@angular/core'; import {
Injectable,
signal,
computed
} from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, throwError, forkJoin } from 'rxjs'; import {
Observable,
of,
throwError,
forkJoin
} from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { ServerInfo, JoinRequest, User } from '../models'; import {
ServerInfo,
JoinRequest,
User
} from '../models';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
/** /**
@@ -137,6 +150,7 @@ export class ServerDirectoryService {
isActive: endpoint.id === endpointId isActive: endpoint.id === endpointId
})) }))
); );
this.saveEndpoints(); this.saveEndpoints();
} }
@@ -148,9 +162,12 @@ export class ServerDirectoryService {
): void { ): void {
this._servers.update((endpoints) => this._servers.update((endpoints) =>
endpoints.map((endpoint) => endpoints.map((endpoint) =>
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint endpoint.id === endpointId ? { ...endpoint,
status,
latency } : endpoint
) )
); );
this.saveEndpoints(); this.saveEndpoints();
} }
@@ -541,7 +558,8 @@ export class ServerDirectoryService {
endpoints = endpoints.map((endpoint) => { endpoints = endpoints.map((endpoint) => {
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) { if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) }; return { ...endpoint,
url: endpoint.url.replace(/^https?/, expectedProtocol) };
} }
return endpoint; return endpoint;
@@ -556,7 +574,8 @@ export class ServerDirectoryService {
/** Create and persist the built-in default endpoint. */ /** Create and persist the built-in default endpoint. */
private initialiseDefaultEndpoint(): void { private initialiseDefaultEndpoint(): void {
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() }; const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT,
id: uuidv4() };
this._servers.set([defaultEndpoint]); this._servers.set([defaultEndpoint]);
this.saveEndpoints(); this.saveEndpoints();

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal, computed } from '@angular/core'; import {
Injectable,
signal,
computed
} from '@angular/core';
/** Default timeout (ms) for the NTP-style HTTP sync request. */ /** Default timeout (ms) for the NTP-style HTTP sync request. */
const DEFAULT_SYNC_TIMEOUT_MS = 5000; const DEFAULT_SYNC_TIMEOUT_MS = 5000;

View File

@@ -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 * and remote peer streams, exposing per-user "speaking" state as
* reactive Angular signals. * reactive Angular signals.
* *
@@ -9,19 +9,26 @@
* // speaking() => true when the user's audio level exceeds the threshold * // speaking() => true when the user's audio level exceeds the threshold
* *
* const volume = voiceActivity.volume(userId); * const volume = voiceActivity.volume(userId);
* // volume() => normalised 01 audio level * // volume() => normalised 0-1 audio level
* ``` * ```
* *
* Internally uses the Web Audio API ({@link AudioContext} + * Internally uses the Web Audio API ({@link AudioContext} +
* {@link AnalyserNode}) per tracked stream, with a single * {@link AnalyserNode}) per tracked stream, with a single
* `requestAnimationFrame` poll loop. * `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 { Subscription } from 'rxjs';
import { WebRTCService } from './webrtc.service'; import { WebRTCService } from './webrtc.service';
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */
/** RMS volume threshold (01) above which a user counts as "speaking". */ /** RMS volume threshold (0-1) above which a user counts as "speaking". */
const SPEAKING_THRESHOLD = 0.015; const SPEAKING_THRESHOLD = 0.015;
/** How many consecutive silent frames before we flip speaking → false. */ /** How many consecutive silent frames before we flip speaking → false. */
const SILENT_FRAME_GRACE = 8; const SILENT_FRAME_GRACE = 8;
@@ -38,7 +45,7 @@ interface TrackedStream {
analyser: AnalyserNode; analyser: AnalyserNode;
/** Reusable buffer for `getByteTimeDomainData`. */ /** Reusable buffer for `getByteTimeDomainData`. */
dataArray: Uint8Array<ArrayBuffer>; dataArray: Uint8Array<ArrayBuffer>;
/** Writable signal for the normalised volume (01). */ /** Writable signal for the normalised volume (0-1). */
volumeSignal: ReturnType<typeof signal<number>>; volumeSignal: ReturnType<typeof signal<number>>;
/** Writable signal for speaking state. */ /** Writable signal for speaking state. */
speakingSignal: ReturnType<typeof signal<boolean>>; speakingSignal: ReturnType<typeof signal<boolean>>;
@@ -123,7 +130,7 @@ export class VoiceActivityService implements OnDestroy {
} }
/** /**
* Returns a read-only signal with the normalised (01) volume * Returns a read-only signal with the normalised (0-1) volume
* for the given user. * for the given user.
*/ */
volume(userId: string): Signal<number> { volume(userId: string): Signal<number> {
@@ -160,7 +167,7 @@ export class VoiceActivityService implements OnDestroy {
analyser.fftSize = FFT_SIZE; analyser.fftSize = FFT_SIZE;
source.connect(analyser); 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. // double-play audio; playback is handled elsewhere.
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>; const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
@@ -226,7 +233,7 @@ export class VoiceActivityService implements OnDestroy {
analyser.getByteTimeDomainData(dataArray); analyser.getByteTimeDomainData(dataArray);
// Compute RMS volume from time-domain data (values 0255, centred at 128). // Compute RMS volume from time-domain data (values 0-255, centred at 128).
let sumSquares = 0; let sumSquares = 0;
for (let i = 0; i < dataArray.length; i++) { for (let i = 0; i < dataArray.length; i++) {
@@ -271,6 +278,7 @@ export class VoiceActivityService implements OnDestroy {
this.tracked.forEach((entry, id) => { this.tracked.forEach((entry, id) => {
map.set(id, entry.speakingSignal()); map.set(id, entry.speakingSignal());
}); });
this._speakingMap.set(map); this._speakingMap.set(map);
} }

View File

@@ -1,5 +1,5 @@
/** /**
* VoiceLevelingService Angular service that manages the * VoiceLevelingService - Angular service that manages the
* per-speaker voice leveling (AGC) system. * per-speaker voice leveling (AGC) system.
* *
* ═══════════════════════════════════════════════════════════════════ * ═══════════════════════════════════════════════════════════════════
@@ -17,7 +17,7 @@
* *
* 4. Provides an `enable` / `disable` / `disableAll` API that * 4. Provides an `enable` / `disable` / `disableAll` API that
* the voice-controls component uses to insert and remove the * 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. * the {@link NoiseReductionManager} toggle pattern.
* *
* 5. Fires a callback when the user toggles the enabled state so * 5. Fires a callback when the user toggles the enabled state so
@@ -26,7 +26,12 @@
* ═══════════════════════════════════════════════════════════════════ * ═══════════════════════════════════════════════════════════════════
*/ */
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal, computed, OnDestroy } from '@angular/core'; import {
Injectable,
signal,
computed,
OnDestroy
} from '@angular/core';
import { import {
VoiceLevelingManager, VoiceLevelingManager,
VoiceLevelingSettings, VoiceLevelingSettings,
@@ -167,7 +172,7 @@ export class VoiceLevelingService implements OnDestroy {
* Set the post-AGC volume for a specific speaker. * Set the post-AGC volume for a specific speaker.
* *
* @param peerId The speaker's peer ID. * @param peerId The speaker's peer ID.
* @param volume Normalised volume (01). * @param volume Normalised volume (0-1).
*/ */
setSpeakerVolume(peerId: string, volume: number): void { setSpeakerVolume(peerId: string, volume: number): void {
this.manager.setSpeakerVolume(peerId, volume); this.manager.setSpeakerVolume(peerId, volume);
@@ -176,7 +181,7 @@ export class VoiceLevelingService implements OnDestroy {
/** /**
* Set the master volume applied after AGC to all speakers. * Set the master volume applied after AGC to all speakers.
* *
* @param volume Normalised volume (01). * @param volume Normalised volume (0-1).
*/ */
setMasterVolume(volume: number): void { setMasterVolume(volume: number): void {
this.manager.setMasterVolume(volume); this.manager.setMasterVolume(volume);
@@ -222,7 +227,7 @@ export class VoiceLevelingService implements OnDestroy {
STORAGE_KEY_VOICE_LEVELING_SETTINGS, STORAGE_KEY_VOICE_LEVELING_SETTINGS,
JSON.stringify(settings) JSON.stringify(settings)
); );
} catch { /* localStorage unavailable ignore */ } } catch { /* localStorage unavailable - ignore */ }
} }
/** Load settings from localStorage and apply to the manager. */ /** Load settings from localStorage and apply to the manager. */
@@ -264,7 +269,7 @@ export class VoiceLevelingService implements OnDestroy {
speed: this._speed(), speed: this._speed(),
noiseGate: this._noiseGate() noiseGate: this._noiseGate()
}); });
} catch { /* corrupted data use defaults */ } } catch { /* corrupted data - use defaults */ }
} }
/* ── Cleanup ─────────────────────────────────────────────────── */ /* ── Cleanup ─────────────────────────────────────────────────── */

View File

@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
import { Injectable, signal, computed, inject } from '@angular/core'; import {
Injectable,
signal,
computed,
inject
} from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
@@ -31,7 +36,7 @@ export interface VoiceSessionInfo {
* navigation so that floating voice controls remain visible when * navigation so that floating voice controls remain visible when
* the user is browsing a different server or view. * 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. * voice management lives in {@link WebRTCService} and its managers.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -132,6 +137,7 @@ export class VoiceSessionService {
} as any } as any
}) })
); );
this._isViewingVoiceServer.set(true); this._isViewingVoiceServer.set(true);
} }

View File

@@ -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/`: * Each concern lives in its own file under `./webrtc/`:
* • SignalingManager WebSocket lifecycle & reconnection * • SignalingManager - WebSocket lifecycle & reconnection
* • PeerConnectionManager RTCPeerConnection, offers/answers, ICE, data channels * • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
* • MediaManager mic voice, mute, deafen, bitrate * • MediaManager - mic voice, mute, deafen, bitrate
* • ScreenShareManager screen capture & mixed audio * • ScreenShareManager - screen capture & mixed audio
* • WebRTCLogger debug / diagnostic logging * • WebRTCLogger - debug / diagnostic logging
* *
* This file wires them together and exposes a public API that is * This file wires them together and exposes a public API that is
* identical to the old monolithic service so consumers don't change. * identical to the old monolithic service so consumers don't change.
*/ */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */
import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core'; import {
Injectable,
signal,
computed,
inject,
OnDestroy
} from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { SignalingMessage, ChatEvent } from '../models'; import { SignalingMessage, ChatEvent } from '../models';
import { TimeSyncService } from './time-sync.service'; import { TimeSyncService } from './time-sync.service';
import { import {
// Managers
SignalingManager, SignalingManager,
PeerConnectionManager, PeerConnectionManager,
MediaManager, MediaManager,
ScreenShareManager, ScreenShareManager,
WebRTCLogger, WebRTCLogger,
// Types
IdentifyCredentials, IdentifyCredentials,
JoinedServerInfo, JoinedServerInfo,
VoiceStateSnapshot, VoiceStateSnapshot,
LatencyProfile, LatencyProfile,
// Constants
SIGNALING_TYPE_IDENTIFY, SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER, SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_VIEW_SERVER, SIGNALING_TYPE_VIEW_SERVER,
@@ -255,6 +258,7 @@ export class WebRTCService implements OnDestroy {
oderId: user.oderId, oderId: user.oderId,
serverId: message.serverId serverId: message.serverId
}); });
this.peerManager.createPeerConnection(user.oderId, true); this.peerManager.createPeerConnection(user.oderId, true);
this.peerManager.createAndSendOffer(user.oderId); this.peerManager.createAndSendOffer(user.oderId);
@@ -273,6 +277,7 @@ export class WebRTCService implements OnDestroy {
displayName: message.displayName, displayName: message.displayName,
oderId: message.oderId oderId: message.oderId
}); });
break; break;
case SIGNALING_TYPE_USER_LEFT: case SIGNALING_TYPE_USER_LEFT:
@@ -337,7 +342,9 @@ export class WebRTCService implements OnDestroy {
}); });
for (const peerId of peersToClose) { for (const peerId of peersToClose) {
this.logger.info('Closing peer from different server', { peerId, currentServer: serverId }); this.logger.info('Closing peer from different server', { peerId,
currentServer: serverId });
this.peerManager.removePeer(peerId); this.peerManager.removePeer(peerId);
this.peerServerMap.delete(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. * @param displayName - The user's display name.
*/ */
identify(oderId: string, displayName: string): void { identify(oderId: string, displayName: string): void {
this.lastIdentifyCredentials = { oderId, displayName }; this.lastIdentifyCredentials = { oderId,
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName }); displayName };
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName });
} }
/** /**
@@ -425,9 +436,12 @@ export class WebRTCService implements OnDestroy {
* @param userId - The local user ID. * @param userId - The local user ID.
*/ */
joinRoom(roomId: string, userId: string): void { joinRoom(roomId: string, userId: string): void {
this.lastJoinedServer = { serverId: roomId, userId }; this.lastJoinedServer = { serverId: roomId,
userId };
this.memberServerIds.add(roomId); 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. * @param userId - The local user ID.
*/ */
switchServer(serverId: string, userId: string): void { switchServer(serverId: string, userId: string): void {
this.lastJoinedServer = { serverId, userId }; this.lastJoinedServer = { serverId,
userId };
if (this.memberServerIds.has(serverId)) { 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)', { this.logger.info('Viewed server (already joined)', {
serverId, serverId,
userId, userId,
@@ -449,7 +466,9 @@ export class WebRTCService implements OnDestroy {
}); });
} else { } else {
this.memberServerIds.add(serverId); 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', { this.logger.info('Joined new server via switch', {
serverId, serverId,
userId, userId,
@@ -469,7 +488,9 @@ export class WebRTCService implements OnDestroy {
leaveRoom(serverId?: string): void { leaveRoom(serverId?: string): void {
if (serverId) { if (serverId) {
this.memberServerIds.delete(serverId); this.memberServerIds.delete(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
serverId });
this.logger.info('Left server', { serverId }); this.logger.info('Left server', { serverId });
if (this.memberServerIds.size === 0) { if (this.memberServerIds.size === 0) {
@@ -480,8 +501,10 @@ export class WebRTCService implements OnDestroy {
} }
this.memberServerIds.forEach((sid) => { 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.memberServerIds.clear();
this.fullCleanup(); this.fullCleanup();
} }
@@ -619,7 +642,7 @@ export class WebRTCService implements OnDestroy {
/** /**
* Set the output volume for remote audio playback. * Set the output volume for remote audio playback.
* *
* @param volume - Normalised volume (01). * @param volume - Normalised volume (0-1).
*/ */
setOutputVolume(volume: number): void { setOutputVolume(volume: number): void {
this.mediaManager.setOutputVolume(volume); this.mediaManager.setOutputVolume(volume);

View File

@@ -114,7 +114,7 @@ export class MediaManager {
getIsSelfDeafened(): boolean { getIsSelfDeafened(): boolean {
return this.isSelfDeafened; return this.isSelfDeafened;
} }
/** Current remote audio output volume (normalised 01). */ /** Current remote audio output volume (normalised 0-1). */
getRemoteAudioVolume(): number { getRemoteAudioVolume(): number {
return this.remoteAudioVolume; return this.remoteAudioVolume;
} }
@@ -231,7 +231,7 @@ export class MediaManager {
*/ */
async setLocalStream(stream: MediaStream): Promise<void> { async setLocalStream(stream: MediaStream): Promise<void> {
this.rawMicStream = stream; 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 // Pipe through the denoiser when the user wants noise reduction
if (this._noiseReductionDesired) { if (this._noiseReductionDesired) {
@@ -259,6 +259,7 @@ export class MediaManager {
audioTracks.forEach((track) => { audioTracks.forEach((track) => {
track.enabled = !newMutedState; track.enabled = !newMutedState;
}); });
this.isMicMuted = newMutedState; this.isMicMuted = newMutedState;
} }
} }
@@ -299,8 +300,9 @@ export class MediaManager {
if (shouldEnable) { if (shouldEnable) {
if (!this.rawMicStream) { if (!this.rawMicStream) {
this.logger.warn( this.logger.warn(
'Cannot enable noise reduction no mic stream yet (will apply on connect)' 'Cannot enable noise reduction - no mic stream yet (will apply on connect)'
); );
return; return;
} }

View File

@@ -18,7 +18,7 @@ import { WebRTCLogger } from './webrtc-logger';
/** Name used to register / instantiate the AudioWorklet processor. */ /** Name used to register / instantiate the AudioWorklet processor. */
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet'; const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
/** RNNoise is trained on 48 kHz audio the AudioContext must match. */ /** RNNoise is trained on 48 kHz audio - the AudioContext must match. */
const RNNOISE_SAMPLE_RATE = 48_000; const RNNOISE_SAMPLE_RATE = 48_000;
/** /**
* Relative path (from the served application root) to the **bundled** * Relative path (from the served application root) to the **bundled**

View File

@@ -123,7 +123,8 @@ export class PeerConnectionManager {
* @returns The newly-created {@link PeerData} record. * @returns The newly-created {@link PeerData} record.
*/ */
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData { 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 }); const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
@@ -136,6 +137,7 @@ export class PeerConnectionManager {
remotePeerId, remotePeerId,
candidateType: (event.candidate as any)?.type candidateType: (event.candidate as any)?.type
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ICE_CANDIDATE, type: SIGNALING_TYPE_ICE_CANDIDATE,
targetUserId: remotePeerId, targetUserId: remotePeerId,
@@ -182,7 +184,8 @@ export class PeerConnectionManager {
}; };
connection.onsignalingstatechange = () => { connection.onsignalingstatechange = () => {
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState }); this.logger.info('signalingstatechange', { remotePeerId,
state: connection.signalingState });
}; };
connection.onnegotiationneeded = () => { connection.onnegotiationneeded = () => {
@@ -302,6 +305,7 @@ export class PeerConnectionManager {
type: offer.type, type: offer.type,
sdpLength: offer.sdp?.length sdpLength: offer.sdp?.length
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER, type: SIGNALING_TYPE_OFFER,
targetUserId: remotePeerId, targetUserId: remotePeerId,
@@ -370,11 +374,15 @@ export class PeerConnectionManager {
const isPolite = localId > fromUserId; const isPolite = localId > fromUserId;
if (!isPolite) { if (!isPolite) {
this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId }); this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId,
return; // Our offer takes priority remote will answer it. 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({ await peerData.connection.setLocalDescription({
type: 'rollback' type: 'rollback'
} as RTCSessionDescriptionInit); } as RTCSessionDescriptionInit);
@@ -438,6 +446,7 @@ export class PeerConnectionManager {
type: answer.type, type: answer.type,
sdpLength: answer.sdp?.length sdpLength: answer.sdp?.length
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ANSWER, type: SIGNALING_TYPE_ANSWER,
targetUserId: fromUserId, targetUserId: fromUserId,
@@ -482,7 +491,7 @@ export class PeerConnectionManager {
peerData.pendingIceCandidates = []; peerData.pendingIceCandidates = [];
} else { } else {
this.logger.warn('Ignoring answer wrong signaling state', { this.logger.warn('Ignoring answer - wrong signaling state', {
state: peerData.connection.signalingState state: peerData.connection.signalingState
}); });
} }
@@ -559,6 +568,7 @@ export class PeerConnectionManager {
type: offer.type, type: offer.type,
sdpLength: offer.sdp?.length sdpLength: offer.sdp?.length
}); });
this.callbacks.sendRawMessage({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER, type: SIGNALING_TYPE_OFFER,
targetUserId: peerId, targetUserId: peerId,
@@ -622,16 +632,19 @@ export class PeerConnectionManager {
* @param message - The parsed JSON payload. * @param message - The parsed JSON payload.
*/ */
private handlePeerMessage(peerId: string, message: any): void { 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) { if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
this.sendCurrentStatesToPeer(peerId); this.sendCurrentStatesToPeer(peerId);
return; return;
} }
// Ping/pong latency measurement handled internally, not forwarded // Ping/pong latency measurement - handled internally, not forwarded
if (message.type === P2P_TYPE_PING) { 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; return;
} }
@@ -642,14 +655,16 @@ export class PeerConnectionManager {
const latencyMs = Math.round(performance.now() - sent); const latencyMs = Math.round(performance.now() - sent);
this.peerLatencies.set(peerId, latencyMs); this.peerLatencies.set(peerId, latencyMs);
this.peerLatencyChanged$.next({ peerId, latencyMs }); this.peerLatencyChanged$.next({ peerId,
latencyMs });
} }
this.pendingPings.delete(peerId); this.pendingPings.delete(peerId);
return; return;
} }
const enriched = { ...message, fromPeerId: peerId }; const enriched = { ...message,
fromPeerId: peerId };
this.messageReceived$.next(enriched); this.messageReceived$.next(enriched);
} }
@@ -682,7 +697,7 @@ export class PeerConnectionManager {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) { if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
this.logger.warn('Peer not connected cannot send', { peerId }); this.logger.warn('Peer not connected - cannot send', { peerId });
return; return;
} }
@@ -706,7 +721,7 @@ export class PeerConnectionManager {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) { if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
this.logger.warn('Peer not connected cannot send buffered', { peerId }); this.logger.warn('Peer not connected - cannot send buffered', { peerId });
return; return;
} }
@@ -748,7 +763,11 @@ export class PeerConnectionManager {
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME; const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = this.callbacks.getVoiceStateSnapshot(); 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, { this.sendToPeer(peerId, {
type: P2P_TYPE_SCREEN_STATE, type: P2P_TYPE_SCREEN_STATE,
oderId, oderId,
@@ -759,10 +778,11 @@ export class PeerConnectionManager {
private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void { private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void {
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) { if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
this.logger.warn('Cannot send states channel not open', { this.logger.warn('Cannot send states - channel not open', {
remotePeerId, remotePeerId,
state: channel.readyState state: channel.readyState
}); });
return; return;
} }
@@ -772,7 +792,11 @@ export class PeerConnectionManager {
const voiceState = this.callbacks.getVoiceStateSnapshot(); const voiceState = this.callbacks.getVoiceStateSnapshot();
try { 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( channel.send(
JSON.stringify({ JSON.stringify({
type: P2P_TYPE_SCREEN_STATE, type: P2P_TYPE_SCREEN_STATE,
@@ -781,7 +805,9 @@ export class PeerConnectionManager {
isScreenSharing: this.callbacks.isScreenSharingActive() isScreenSharing: this.callbacks.isScreenSharingActive()
}) })
); );
this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
this.logger.info('Sent initial states to channel', { remotePeerId,
voiceState });
} catch (e) { } catch (e) {
this.logger.error('Failed to send initial states to channel', 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 displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = this.callbacks.getVoiceStateSnapshot(); 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({ this.broadcastMessage({
type: P2P_TYPE_SCREEN_STATE, type: P2P_TYPE_SCREEN_STATE,
oderId, oderId,
@@ -816,6 +846,7 @@ export class PeerConnectionManager {
readyState: track.readyState, readyState: track.readyState,
settings settings
}); });
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`); this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
// Skip inactive video placeholder tracks // Skip inactive video placeholder tracks
@@ -825,6 +856,7 @@ export class PeerConnectionManager {
enabled: track.enabled, enabled: track.enabled,
readyState: track.readyState readyState: track.readyState
}); });
return; return;
} }
@@ -843,7 +875,8 @@ export class PeerConnectionManager {
} }
this.remotePeerStreams.set(remotePeerId, compositeStream); 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(); peerData.connection.close();
}); });
this.activePeerConnections.clear(); this.activePeerConnections.clear();
this.peerNegotiationQueue.clear(); this.peerNegotiationQueue.clear();
this.peerLatencies.clear(); this.peerLatencies.clear();
@@ -924,7 +958,8 @@ export class PeerConnectionManager {
} }
info.reconnectAttempts++; 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) { if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
this.logger.info('P2P reconnect max attempts reached', { peerId }); this.logger.info('P2P reconnect max attempts reached', { peerId });
@@ -934,7 +969,7 @@ export class PeerConnectionManager {
} }
if (!this.callbacks.isSignalingConnected()) { 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; return;
} }
@@ -996,6 +1031,7 @@ export class PeerConnectionManager {
this.connectedPeersList = this.connectedPeersList.filter( this.connectedPeersList = this.connectedPeersList.filter(
(connectedId) => connectedId !== peerId (connectedId) => connectedId !== peerId
); );
this.connectedPeersChanged$.next(this.connectedPeersList); this.connectedPeersChanged$.next(this.connectedPeersList);
} }
@@ -1047,7 +1083,8 @@ export class PeerConnectionManager {
this.pendingPings.set(peerId, ts); this.pendingPings.set(peerId, ts);
try { try {
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts })); peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING,
ts }));
} catch { } catch {
/* ignore */ /* ignore */
} }

View File

@@ -80,11 +80,13 @@ export class ScreenShareManager {
const sources = await (window as any).electronAPI.getSources(); const sources = await (window as any).electronAPI.getSources();
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0]; const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
const electronConstraints: any = { const electronConstraints: any = {
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } } video: { mandatory: { chromeMediaSource: 'desktop',
chromeMediaSourceId: screenSource.id } }
}; };
if (includeSystemAudio) { if (includeSystemAudio) {
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }; electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop',
chromeMediaSourceId: screenSource.id } };
} else { } else {
electronConstraints.audio = false; electronConstraints.audio = false;
} }
@@ -109,7 +111,9 @@ export class ScreenShareManager {
height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT }, height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT },
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE } frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }
}, },
audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false audio: includeSystemAudio ? { echoCancellation: false,
noiseSuppression: false,
autoGainControl: false } : false
} as any; } as any;
this.logger.info('getDisplayMedia constraints', displayConstraints); this.logger.info('getDisplayMedia constraints', displayConstraints);

View File

@@ -24,7 +24,7 @@ export class SignalingManager {
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null; private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null; private stateHeartbeatTimer: ReturnType<typeof setInterval> | 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<void>(); readonly heartbeatTick$ = new Subject<void>();
/** Fires whenever a raw signaling message arrives from the server. */ /** Fires whenever a raw signaling message arrives from the server. */
@@ -73,14 +73,18 @@ export class SignalingManager {
this.signalingWebSocket.onerror = (error) => { this.signalingWebSocket.onerror = (error) => {
this.logger.error('Signaling socket error', 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); observer.error(error);
}; };
this.signalingWebSocket.onclose = () => { this.signalingWebSocket.onclose = () => {
this.logger.info('Disconnected from signaling server'); this.logger.info('Disconnected from signaling server');
this.stopHeartbeat(); this.stopHeartbeat();
this.connectionStatus$.next({ connected: false, errorMessage: 'Disconnected from signaling server' }); this.connectionStatus$.next({ connected: false,
errorMessage: 'Disconnected from signaling server' });
this.scheduleReconnect(); this.scheduleReconnect();
}; };
} catch (error) { } catch (error) {
@@ -118,7 +122,9 @@ export class SignalingManager {
return; return;
} }
const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() }; const fullMessage: SignalingMessage = { ...message,
from: localPeerId,
timestamp: Date.now() };
this.signalingWebSocket!.send(JSON.stringify(fullMessage)); this.signalingWebSocket!.send(JSON.stringify(fullMessage));
} }
@@ -159,25 +165,31 @@ export class SignalingManager {
const credentials = this.getLastIdentify(); const credentials = this.getLastIdentify();
if (credentials) { if (credentials) {
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName }); this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
oderId: credentials.oderId,
displayName: credentials.displayName });
} }
const memberIds = this.getMemberServerIds(); const memberIds = this.getMemberServerIds();
if (memberIds.size > 0) { if (memberIds.size > 0) {
memberIds.forEach((serverId) => { memberIds.forEach((serverId) => {
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId });
}); });
const lastJoined = this.getLastJoinedServer(); const lastJoined = this.getLastJoinedServer();
if (lastJoined) { if (lastJoined) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
serverId: lastJoined.serverId });
} }
} else { } else {
const lastJoined = this.getLastJoinedServer(); const lastJoined = this.getLastJoinedServer();
if (lastJoined) { if (lastJoined) {
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId }); this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId: lastJoined.serverId });
} }
} }
} }

View File

@@ -1,6 +1,6 @@
/* eslint-disable id-length, max-statements-per-line */ /* eslint-disable id-length, max-statements-per-line */
/** /**
* VoiceLevelingManager manages per-speaker automatic gain control * VoiceLevelingManager - manages per-speaker automatic gain control
* pipelines for remote voice streams. * pipelines for remote voice streams.
* *
* ═══════════════════════════════════════════════════════════════════ * ═══════════════════════════════════════════════════════════════════
@@ -13,9 +13,9 @@
* ↓ * ↓
* MediaStreamSource (AudioContext) * 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 * MediaStreamDestination → leveled MediaStream
* *
@@ -26,7 +26,7 @@
* for browsers that don't support AudioWorklet or SharedArrayBuffer. * 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 * • `enable(peerId, rawStream)` builds the pipeline and returns a
@@ -37,7 +37,7 @@
* *
* The calling component keeps a reference to the original raw stream * The calling component keeps a reference to the original raw stream
* and swaps the Audio element's `srcObject` between the 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. * like noise reduction does for the local mic.
* *
* ═══════════════════════════════════════════════════════════════════ * ═══════════════════════════════════════════════════════════════════
@@ -90,7 +90,7 @@ interface SpeakerPipeline {
/** AudioWorklet module path (served from public/). */ /** AudioWorklet module path (served from public/). */
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js'; const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
/** Processor name must match `registerProcessor` in the worklet. */ /** Processor name - must match `registerProcessor` in the worklet. */
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor'; const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
/* ──────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────── */
@@ -134,7 +134,9 @@ export class VoiceLevelingManager {
* Only provided keys are updated; the rest stay unchanged. * Only provided keys are updated; the rest stay unchanged.
*/ */
updateSettings(partial: Partial<VoiceLevelingSettings>): void { updateSettings(partial: Partial<VoiceLevelingSettings>): void {
this._settings = { ...this._settings, ...partial }; this._settings = { ...this._settings,
...partial };
this.pipelines.forEach((p) => this._pushSettingsToPipeline(p)); this.pipelines.forEach((p) => this._pushSettingsToPipeline(p));
} }
@@ -180,6 +182,7 @@ export class VoiceLevelingManager {
peerId, peerId,
fallback: pipeline.isFallback fallback: pipeline.isFallback
}); });
return pipeline.destination.stream; return pipeline.destination.stream;
} catch (err) { } catch (err) {
this.logger.error('VoiceLeveling: pipeline build failed, returning raw stream', err); this.logger.error('VoiceLeveling: pipeline build failed, returning raw stream', err);

View File

@@ -46,9 +46,14 @@ export class WebRTCLogger {
settings settings
}); });
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind })); track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id,
track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id, kind: track.kind })); kind: track.kind }));
track.addEventListener('unmute', () => this.info(`Track unmuted: ${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. */ /** Log a MediaStream summary and attach diagnostics to every track. */
@@ -65,8 +70,10 @@ export class WebRTCLogger {
id: (stream as any).id, id: (stream as any).id,
audioTrackCount: audioTracks.length, audioTrackCount: audioTracks.length,
videoTrackCount: videoTracks.length, videoTrackCount: videoTracks.length,
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind })) allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id,
kind: streamTrack.kind }))
}); });
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`)); audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`)); videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
} }

View File

@@ -30,9 +30,9 @@ export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
/** Data channel name used for P2P chat */ /** Data channel name used for P2P chat */
export const DATA_CHANNEL_LABEL = '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 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 DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
export const SCREEN_SHARE_IDEAL_WIDTH = 1920; export const SCREEN_SHARE_IDEAL_WIDTH = 1920;

View File

@@ -2,7 +2,10 @@
<div class="h-full flex flex-col bg-card"> <div class="h-full flex flex-col bg-card">
<!-- Header --> <!-- Header -->
<div class="p-4 border-b border-border flex items-center gap-2"> <div class="p-4 border-b border-border flex items-center gap-2">
<ng-icon name="lucideShield" class="w-5 h-5 text-primary" /> <ng-icon
name="lucideShield"
class="w-5 h-5 text-primary"
/>
<h2 class="font-semibold text-foreground">Admin Panel</h2> <h2 class="font-semibold text-foreground">Admin Panel</h2>
</div> </div>
@@ -17,7 +20,10 @@
[class.border-primary]="activeTab() === 'settings'" [class.border-primary]="activeTab() === 'settings'"
[class.text-muted-foreground]="activeTab() !== 'settings'" [class.text-muted-foreground]="activeTab() !== 'settings'"
> >
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" /> <ng-icon
name="lucideSettings"
class="w-4 h-4 inline mr-1"
/>
Settings Settings
</button> </button>
<button <button
@@ -29,7 +35,10 @@
[class.border-primary]="activeTab() === 'members'" [class.border-primary]="activeTab() === 'members'"
[class.text-muted-foreground]="activeTab() !== 'members'" [class.text-muted-foreground]="activeTab() !== 'members'"
> >
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" /> <ng-icon
name="lucideUsers"
class="w-4 h-4 inline mr-1"
/>
Members Members
</button> </button>
<button <button
@@ -41,7 +50,10 @@
[class.border-primary]="activeTab() === 'bans'" [class.border-primary]="activeTab() === 'bans'"
[class.text-muted-foreground]="activeTab() !== 'bans'" [class.text-muted-foreground]="activeTab() !== 'bans'"
> >
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" /> <ng-icon
name="lucideBan"
class="w-4 h-4 inline mr-1"
/>
Bans Bans
</button> </button>
<button <button
@@ -53,7 +65,10 @@
[class.border-primary]="activeTab() === 'permissions'" [class.border-primary]="activeTab() === 'permissions'"
[class.text-muted-foreground]="activeTab() !== 'permissions'" [class.text-muted-foreground]="activeTab() !== 'permissions'"
> >
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" /> <ng-icon
name="lucideShield"
class="w-4 h-4 inline mr-1"
/>
Perms Perms
</button> </button>
</div> </div>
@@ -67,7 +82,11 @@
<!-- Room Name --> <!-- Room Name -->
<div> <div>
<label for="room-name-input" class="block text-sm text-muted-foreground mb-1">Room Name</label> <label
for="room-name-input"
class="block text-sm text-muted-foreground mb-1"
>Room Name</label
>
<input <input
type="text" type="text"
id="room-name-input" id="room-name-input"
@@ -78,7 +97,11 @@
<!-- Room Description --> <!-- Room Description -->
<div> <div>
<label for="room-description-input" class="block text-sm text-muted-foreground mb-1">Description</label> <label
for="room-description-input"
class="block text-sm text-muted-foreground mb-1"
>Description</label
>
<textarea <textarea
id="room-description-input" id="room-description-input"
[(ngModel)]="roomDescription" [(ngModel)]="roomDescription"
@@ -103,16 +126,26 @@
[class.text-muted-foreground]="!isPrivate()" [class.text-muted-foreground]="!isPrivate()"
> >
@if (isPrivate()) { @if (isPrivate()) {
<ng-icon name="lucideLock" class="w-4 h-4" /> <ng-icon
name="lucideLock"
class="w-4 h-4"
/>
} @else { } @else {
<ng-icon name="lucideUnlock" class="w-4 h-4" /> <ng-icon
name="lucideUnlock"
class="w-4 h-4"
/>
} }
</button> </button>
</div> </div>
<!-- Max Users --> <!-- Max Users -->
<div> <div>
<label for="max-users-input" class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label> <label
for="max-users-input"
class="block text-sm text-muted-foreground mb-1"
>Max Users (0 = unlimited)</label
>
<input <input
type="number" type="number"
id="max-users-input" id="max-users-input"
@@ -128,7 +161,10 @@
(click)="saveSettings()" (click)="saveSettings()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2" class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
> >
<ng-icon name="lucideCheck" class="w-4 h-4" /> <ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Settings Save Settings
</button> </button>
@@ -140,7 +176,10 @@
(click)="confirmDeleteRoom()" (click)="confirmDeleteRoom()"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2" class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
> >
<ng-icon name="lucideTrash2" class="w-4 h-4" /> <ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room Delete Room
</button> </button>
</div> </div>
@@ -151,13 +190,14 @@
<h3 class="text-sm font-medium text-foreground">Server Members</h3> <h3 class="text-sm font-medium text-foreground">Server Members</h3>
@if (membersFiltered().length === 0) { @if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8"> <p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
No other members online
</p>
} @else { } @else {
@for (user of membersFiltered(); track user.id) { @for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar [name]="user.displayName || '?'" size="sm" /> <app-user-avatar
[name]="user.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p> <p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
@@ -188,7 +228,10 @@
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors" class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick" title="Kick"
> >
<ng-icon name="lucideUserX" class="w-4 h-4" /> <ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button> </button>
<button <button
type="button" type="button"
@@ -196,7 +239,10 @@
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors" class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban" title="Ban"
> >
<ng-icon name="lucideBan" class="w-4 h-4" /> <ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button> </button>
</div> </div>
} }
@@ -210,9 +256,7 @@
<h3 class="text-sm font-medium text-foreground">Banned Users</h3> <h3 class="text-sm font-medium text-foreground">Banned Users</h3>
@if (bannedUsers().length === 0) { @if (bannedUsers().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8"> <p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
No banned users
</p>
} @else { } @else {
@for (ban of bannedUsers(); track ban.oderId) { @for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
@@ -224,14 +268,10 @@
{{ ban.displayName || 'Unknown User' }} {{ ban.displayName || 'Unknown User' }}
</p> </p>
@if (ban.reason) { @if (ban.reason) {
<p class="text-xs text-muted-foreground truncate"> <p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
Reason: {{ ban.reason }}
</p>
} }
@if (ban.expiresAt) { @if (ban.expiresAt) {
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
Expires: {{ formatExpiry(ban.expiresAt) }}
</p>
} @else { } @else {
<p class="text-xs text-destructive">Permanent</p> <p class="text-xs text-destructive">Permanent</p>
} }
@@ -241,7 +281,10 @@
(click)="unbanUser(ban)" (click)="unbanUser(ban)"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground" class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
> >
<ng-icon name="lucideX" class="w-4 h-4" /> <ng-icon
name="lucideX"
class="w-4 h-4"
/>
</button> </button>
</div> </div>
} }
@@ -363,7 +406,10 @@
(click)="savePermissions()" (click)="savePermissions()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2" class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
> >
<ng-icon name="lucideCheck" class="w-4 h-4" /> <ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Permissions Save Permissions
</button> </button>
</div> </div>

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -35,7 +39,13 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
@Component({ @Component({
selector: 'app-admin-panel', selector: 'app-admin-panel',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent], imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideShield, lucideShield,
@@ -181,7 +191,8 @@ export class AdminPanelComponent {
formatExpiry(timestamp: number): string { formatExpiry(timestamp: number): string {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' });
} }
// Members tab: get all users except self // Members tab: get all users except self
@@ -194,7 +205,9 @@ export class AdminPanelComponent {
/** Change a member's role and broadcast the update to all peers. */ /** Change a member's role and broadcast the update to all peers. */
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void { changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'role-change', type: 'role-change',
targetUserId: user.id, targetUserId: user.id,

View File

@@ -1,13 +1,20 @@
<div class="h-full grid place-items-center bg-background"> <div class="h-full grid place-items-center bg-background">
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm"> <div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideLogIn" class="w-5 h-5 text-primary" /> <ng-icon
name="lucideLogIn"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Login</h1> <h1 class="text-lg font-semibold text-foreground">Login</h1>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label for="login-username" class="block text-xs text-muted-foreground mb-1">Username</label> <label
for="login-username"
class="block text-xs text-muted-foreground mb-1"
>Username</label
>
<input <input
[(ngModel)]="username" [(ngModel)]="username"
type="text" type="text"
@@ -16,7 +23,11 @@
/> />
</div> </div>
<div> <div>
<label for="login-password" class="block text-xs text-muted-foreground mb-1">Password</label> <label
for="login-password"
class="block text-xs text-muted-foreground mb-1"
>Password</label
>
<input <input
[(ngModel)]="password" [(ngModel)]="password"
type="password" type="password"
@@ -25,7 +36,11 @@
/> />
</div> </div>
<div> <div>
<label for="login-server" class="block text-xs text-muted-foreground mb-1">Server App</label> <label
for="login-server"
class="block text-xs text-muted-foreground mb-1"
>Server App</label
>
<select <select
[(ngModel)]="serverId" [(ngModel)]="serverId"
id="login-server" id="login-server"

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */ /* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
import { Component, inject, signal } from '@angular/core'; import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [provideIcons({ lucideLogIn })], viewProviders: [provideIcons({ lucideLogIn })],
templateUrl: './login.component.html' templateUrl: './login.component.html'
}) })
@@ -43,7 +51,9 @@ export class LoginComponent {
this.error.set(null); this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id; const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({ this.auth.login({ username: this.username.trim(),
password: this.password,
serverId: sid }).subscribe({
next: (resp) => { next: (resp) => {
if (sid) if (sid)
this.serversSvc.setActiveServer(sid); this.serversSvc.setActiveServer(sid);

View File

@@ -1,13 +1,20 @@
<div class="h-full grid place-items-center bg-background"> <div class="h-full grid place-items-center bg-background">
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm"> <div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideUserPlus" class="w-5 h-5 text-primary" /> <ng-icon
name="lucideUserPlus"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Register</h1> <h1 class="text-lg font-semibold text-foreground">Register</h1>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label for="register-username" class="block text-xs text-muted-foreground mb-1">Username</label> <label
for="register-username"
class="block text-xs text-muted-foreground mb-1"
>Username</label
>
<input <input
[(ngModel)]="username" [(ngModel)]="username"
type="text" type="text"
@@ -16,7 +23,11 @@
/> />
</div> </div>
<div> <div>
<label for="register-display-name" class="block text-xs text-muted-foreground mb-1">Display Name</label> <label
for="register-display-name"
class="block text-xs text-muted-foreground mb-1"
>Display Name</label
>
<input <input
[(ngModel)]="displayName" [(ngModel)]="displayName"
type="text" type="text"
@@ -25,7 +36,11 @@
/> />
</div> </div>
<div> <div>
<label for="register-password" class="block text-xs text-muted-foreground mb-1">Password</label> <label
for="register-password"
class="block text-xs text-muted-foreground mb-1"
>Password</label
>
<input <input
[(ngModel)]="password" [(ngModel)]="password"
type="password" type="password"
@@ -34,7 +49,11 @@
/> />
</div> </div>
<div> <div>
<label for="register-server" class="block text-xs text-muted-foreground mb-1">Server App</label> <label
for="register-server"
class="block text-xs text-muted-foreground mb-1"
>Server App</label
>
<select <select
[(ngModel)]="serverId" [(ngModel)]="serverId"
id="register-server" id="register-server"

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */ /* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
import { Component, inject, signal } from '@angular/core'; import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [provideIcons({ lucideUserPlus })], viewProviders: [provideIcons({ lucideUserPlus })],
templateUrl: './register.component.html' templateUrl: './register.component.html'
}) })
@@ -44,7 +52,10 @@ export class RegisterComponent {
this.error.set(null); this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id; const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({ this.auth.register({ username: this.username.trim(),
password: this.password,
displayName: this.displayName.trim(),
serverId: sid }).subscribe({
next: (resp) => { next: (resp) => {
if (sid) if (sid)
this.serversSvc.setActiveServer(sid); this.serversSvc.setActiveServer(sid);

View File

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

View File

@@ -4,14 +4,22 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide'; import {
lucideUser,
lucideLogIn,
lucideUserPlus
} from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
@Component({ @Component({
selector: 'app-user-bar', selector: 'app-user-bar',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon], imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })], viewProviders: [
provideIcons({ lucideUser,
lucideLogIn,
lucideUserPlus })
],
templateUrl: './user-bar.component.html' templateUrl: './user-bar.component.html'
}) })
/** /**

View File

@@ -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 */ /* 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 {
Component,
inject,
signal,
computed,
effect,
ElementRef,
ViewChild,
AfterViewChecked,
OnInit,
OnDestroy,
ChangeDetectorRef
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -21,7 +33,11 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { MessagesActions } from '../../../store/messages/messages.actions'; 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 { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models'; import { Message } from '../../../core/models';
@@ -36,12 +52,30 @@ import remarkParse from 'remark-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { ChatMarkdownService } from './services/chat-markdown.service'; import { ChatMarkdownService } from './services/chat-markdown.service';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀']; const COMMON_EMOJIS = [
'👍',
'❤️',
'😂',
'😮',
'😢',
'🎉',
'🔥',
'👀'
];
@Component({ @Component({
selector: 'app-chat-messages', selector: 'app-chat-messages',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent], imports: [
CommonModule,
FormsModule,
NgIcon,
ContextMenuComponent,
UserAvatarComponent,
TypingIndicatorComponent,
RemarkModule,
MermaidComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideSend, 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(() => { messages = computed(() => {
const all = this.allChannelMessages(); const all = this.allChannelMessages();
const limit = this.displayLimit(); const limit = this.displayLimit();
@@ -391,7 +425,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const el = container.querySelector(`[data-message-id="${messageId}"]`); const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (el) { if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: 'smooth',
block: 'center' });
el.classList.add('bg-primary/10'); el.classList.add('bg-primary/10');
setTimeout(() => el.classList.remove('bg-primary/10'), 2000); 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. */ /** Add a reaction emoji to a message. */
addReaction(messageId: string, emoji: string): void { addReaction(messageId: string, emoji: string): void {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji })); this.store.dispatch(MessagesActions.addReaction({ messageId,
emoji }));
this.showEmojiPicker.set(null); this.showEmojiPicker.set(null);
} }
@@ -423,9 +461,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
); );
if (hasReacted) { if (hasReacted) {
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji })); this.store.dispatch(MessagesActions.removeReaction({ messageId,
emoji }));
} else { } 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; const currentUserId = this.currentUser()?.id;
message.reactions.forEach((reaction) => { message.reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false }; const existing = groups.get(reaction.emoji) || { count: 0,
hasCurrentUser: false };
groups.set(reaction.emoji, { groups.set(reaction.emoji, {
count: existing.count + 1, count: existing.count + 1,
@@ -458,7 +499,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number): string {
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(this.nowRef); const now = new Date(this.nowRef);
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const time = date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' });
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker // Compare calendar days (midnight-aligned) to avoid NG0100 flicker
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24)); const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
@@ -470,7 +512,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} else if (dayDiff < 7) { } else if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time; return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
} else { } 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(() => { this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snap); requestAnimationFrame(snap);
}); });
this.initialScrollObserver.observe(el, { this.initialScrollObserver.observe(el, {
childList: true, childList: true,
subtree: true, subtree: true,
@@ -550,7 +594,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const el = this.messagesContainer.nativeElement; const el = this.messagesContainer.nativeElement;
try { try {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); el.scrollTo({ top: el.scrollHeight,
behavior: 'smooth' });
} catch { } catch {
// Fallback if smooth not supported // Fallback if smooth not supported
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
@@ -591,7 +636,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.stopInitialScrollWatch(); this.stopInitialScrollWatch();
} }
// Infinite scroll upwards load older messages when near the top // Infinite scroll upwards - load older messages when near the top
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) { if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore(); this.loadMore();
} }
@@ -640,7 +685,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private getSelection(): { start: number; end: number } { private getSelection(): { start: number; end: number } {
const el = this.messageInputRef?.nativeElement; const el = this.messageInputRef?.nativeElement;
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length }; return { start: el?.selectionStart ?? this.messageContent.length,
end: el?.selectionEnd ?? this.messageContent.length };
} }
private setSelection(start: number, end: number): void { private setSelection(start: number, end: number): void {
@@ -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). */ /** Format a byte count into a human-readable size string (B, KB, MB, GB). */
formatBytes(bytes: number): string { formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB']; const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes; let size = bytes;
let i = 0; let i = 0;
@@ -787,7 +838,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
if (!bps || bps <= 0) if (!bps || bps <= 0)
return '0 B/s'; return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; const units = [
'B/s',
'KB/s',
'MB/s',
'GB/s'
];
let speed = bps; let speed = bps;
let i = 0; let i = 0;
@@ -855,7 +911,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
openImageContextMenu(event: MouseEvent, att: Attachment): void { openImageContextMenu(event: MouseEvent, att: Attachment): void {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); 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. */ /** Close the image context menu. */
@@ -876,9 +934,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
// Convert to PNG for clipboard compatibility // Convert to PNG for clipboard compatibility
const pngBlob = await this.convertToPng(blob); const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([ await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
new ClipboardItem({ 'image/png': pngBlob })
]);
} catch (_error) { } catch (_error) {
// Failed to copy image to clipboard // Failed to copy image to clipboard
} }

View File

@@ -21,7 +21,9 @@ export class ChatMarkdownService {
const newText = `${before}${token}${selected}${token}${after}`; const newText = `${before}${token}${selected}${token}${after}`;
const cursor = before.length + token.length + selected.length + token.length; const cursor = before.length + token.length + selected.length + token.length;
return { text: newText, selectionStart: cursor, selectionEnd: cursor }; return { text: newText,
selectionStart: cursor,
selectionEnd: cursor };
} }
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult { applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
@@ -34,7 +36,9 @@ export class ChatMarkdownService {
const text = `${before}${newSelected}${after}`; const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length; const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text,
selectionStart: cursor,
selectionEnd: cursor };
} }
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult { applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
@@ -49,7 +53,9 @@ export class ChatMarkdownService {
const text = `${before}${block}${after}`; const text = `${before}${block}${after}`;
const cursor = before.length + block.length; const cursor = before.length + block.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text,
selectionStart: cursor,
selectionEnd: cursor };
} }
applyOrderedList(content: string, selection: SelectionRange): ComposeResult { applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
@@ -62,7 +68,9 @@ export class ChatMarkdownService {
const text = `${before}${newSelected}${after}`; const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length; const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text,
selectionStart: cursor,
selectionEnd: cursor };
} }
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult { applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
@@ -74,7 +82,9 @@ export class ChatMarkdownService {
const text = `${before}${fenced}${after}`; const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length; const cursor = before.length + fenced.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text,
selectionStart: cursor,
selectionEnd: cursor };
} }
applyLink(content: string, selection: SelectionRange): ComposeResult { applyLink(content: string, selection: SelectionRange): ComposeResult {
@@ -87,7 +97,9 @@ export class ChatMarkdownService {
const cursorStart = before.length + link.length - 1; const cursorStart = before.length + link.length - 1;
// Position inside the URL placeholder // Position inside the URL placeholder
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 }; return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
} }
applyImage(content: string, selection: SelectionRange): ComposeResult { applyImage(content: string, selection: SelectionRange): ComposeResult {
@@ -99,7 +111,9 @@ export class ChatMarkdownService {
const text = `${before}${img}${after}`; const text = `${before}${img}${after}`;
const cursorStart = before.length + img.length - 1; const cursorStart = before.length + img.length - 1;
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 }; return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
} }
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult { applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
@@ -110,7 +124,9 @@ export class ChatMarkdownService {
const text = `${before}${hr}${after}`; const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length; const cursor = before.length + hr.length;
return { text, selectionStart: cursor, selectionEnd: cursor }; return { text,
selectionStart: cursor,
selectionEnd: cursor };
} }
appendImageMarkdown(content: string): string { appendImageMarkdown(content: string): string {

View File

@@ -1,8 +1,19 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
import { Component, inject, signal, DestroyRef } from '@angular/core'; import {
Component,
inject,
signal,
DestroyRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
import { merge, interval, filter, map, tap } from 'rxjs'; import {
merge,
interval,
filter,
map,
tap
} from 'rxjs';
const TYPING_TTL = 3_000; const TYPING_TTL = 3_000;
const PURGE_INTERVAL = 1_000; const PURGE_INTERVAL = 1_000;

View File

@@ -1,161 +1,202 @@
<!-- Header --> <!-- Header -->
<div class="p-4 border-b border-border"> <div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3> <h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p> <p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) { @if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) { @for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1"> <span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span> <span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }} {{ v.displayName }}
</span> </span>
} }
</div> </div>
} }
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<div
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
(click)="toggleUserMenu(user.id)"
(keydown.enter)="toggleUserMenu(user.id)"
(keydown.space)="toggleUserMenu(user.id)"
(keyup.enter)="toggleUserMenu(user.id)"
(keyup.space)="toggleUserMenu(user.id)"
role="button"
tabindex="0"
>
<!-- Avatar with online indicator -->
<div class="relative">
<app-user-avatar
[name]="user.displayName"
size="sm"
/>
<span
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div> </div>
<!-- User List --> <!-- User Info -->
<div class="flex-1 overflow-y-auto p-2 space-y-1"> <div class="flex-1 min-w-0">
@for (user of onlineUsers(); track user.id) { <div class="flex items-center gap-1">
<div <span class="font-medium text-sm text-foreground truncate">
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer" {{ user.displayName }}
(click)="toggleUserMenu(user.id)" </span>
(keydown.enter)="toggleUserMenu(user.id)" @if (user.isAdmin) {
(keydown.space)="toggleUserMenu(user.id)" <ng-icon
(keyup.enter)="toggleUserMenu(user.id)" name="lucideShield"
(keyup.space)="toggleUserMenu(user.id)" class="w-3 h-3 text-primary"
role="button" />
tabindex="0" }
> @if (user.isRoomOwner) {
<!-- Avatar with online indicator --> <ng-icon
<div class="relative"> name="lucideCrown"
<app-user-avatar [name]="user.displayName" size="sm" /> class="w-3 h-3 text-yellow-500"
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card" />
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'" }
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'" </div>
></span>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
}
@if (user.isRoomOwner) {
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
}
</div>
</div>
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
} @else if (user.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
} @else if (user.voiceState?.isConnected) {
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
}
@if (user.screenShareState?.isSharing) {
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="menu"
tabindex="0"
>
@if (user.voiceState?.isConnected) {
<button
type="button"
(click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon name="lucideVolume2" class="w-4 h-4" />
<span>Unmute</span>
} @else {
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
<span>Mute</span>
}
</button>
}
<button
type="button"
(click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
<span>Kick</span>
</button>
<button
type="button"
(click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
<span>Ban</span>
</button>
</div>
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">
No users online
</div>
}
</div> </div>
<!-- Ban Dialog --> <!-- Voice/Screen Status -->
@if (showBanDialog()) { <div class="flex items-center gap-1">
<app-confirm-dialog @if (user.voiceState?.isSpeaking) {
title="Ban User" <ng-icon
confirmLabel="Ban User" name="lucideMic"
variant="danger" class="w-4 h-4 text-green-500 animate-pulse"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
</p>
<div class="mb-4">
<label for="ban-reason-input" class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
id="ban-reason-input"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</div> } @else if (user.voiceState?.isMuted) {
<ng-icon
name="lucideMicOff"
class="w-4 h-4 text-muted-foreground"
/>
} @else if (user.voiceState?.isConnected) {
<ng-icon
name="lucideMic"
class="w-4 h-4 text-muted-foreground"
/>
}
<div> @if (user.screenShareState?.isSharing) {
<label for="ban-duration-select" class="block text-sm font-medium text-foreground mb-1">Duration</label> <ng-icon
<select name="lucideMonitor"
[(ngModel)]="banDuration" class="w-4 h-4 text-primary"
id="ban-duration-select" />
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary" }
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="menu"
tabindex="0"
>
@if (user.voiceState?.isConnected) {
<button
type="button"
(click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon
name="lucideVolume2"
class="w-4 h-4"
/>
<span>Unmute</span>
} @else {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4"
/>
<span>Mute</span>
}
</button>
}
<button
type="button"
(click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
> >
<option value="3600000">1 hour</option> <ng-icon
<option value="86400000">1 day</option> name="lucideUserX"
<option value="604800000">1 week</option> class="w-4 h-4"
<option value="2592000000">30 days</option> />
<option value="0">Permanent</option> <span>Kick</span>
</select> </button>
<button
type="button"
(click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
<span>Ban</span>
</button>
</div> </div>
</app-confirm-dialog> }
} </div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
}
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
>?
</p>
<div class="mb-4">
<label
for="ban-reason-input"
class="block text-sm font-medium text-foreground mb-1"
>Reason (optional)</label
>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
id="ban-reason-input"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="ban-duration-select"
class="block text-sm font-medium text-foreground mb-1"
>Duration</label
>
<select
[(ngModel)]="banDuration"
id="ban-duration-select"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
</div>
</app-confirm-dialog>
}

View File

@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal, computed } from '@angular/core'; import {
Component,
inject,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -29,7 +34,13 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
@Component({ @Component({
selector: 'app-user-list', selector: 'app-user-list',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent], imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideMic, lucideMic,

View File

@@ -29,7 +29,10 @@
<!-- No Room Selected --> <!-- No Room Selected -->
<div class="flex-1 flex items-center justify-center"> <div class="flex-1 flex items-center justify-center">
<div class="text-center text-muted-foreground"> <div class="text-center text-muted-foreground">
<ng-icon name="lucideHash" class="w-16 h-16 mx-auto mb-4 opacity-30" /> <ng-icon
name="lucideHash"
class="w-16 h-16 mx-auto mb-4 opacity-30"
/>
<h2 class="text-xl font-medium mb-2">No room selected</h2> <h2 class="text-xl font-medium mb-2">No room selected</h2>
<p class="text-sm">Select or create a room to start chatting</p> <p class="text-sm">Select or create a room to start chatting</p>
</div> </div>

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import {
Component,
inject,
signal
} from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';

View File

@@ -13,7 +13,10 @@
[class.text-muted-foreground]="activeTab() !== 'channels'" [class.text-muted-foreground]="activeTab() !== 'channels'"
[class.hover:text-foreground]="activeTab() !== 'channels'" [class.hover:text-foreground]="activeTab() !== 'channels'"
> >
<ng-icon name="lucideHash" class="w-4 h-4" /> <ng-icon
name="lucideHash"
class="w-4 h-4"
/>
<span>Channels</span> <span>Channels</span>
</button> </button>
<button <button
@@ -25,11 +28,12 @@
[class.text-muted-foreground]="activeTab() !== 'users'" [class.text-muted-foreground]="activeTab() !== 'users'"
[class.hover:text-foreground]="activeTab() !== 'users'" [class.hover:text-foreground]="activeTab() !== 'users'"
> >
<ng-icon name="lucideUsers" class="w-4 h-4" /> <ng-icon
name="lucideUsers"
class="w-4 h-4"
/>
<span>Users</span> <span>Users</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ <span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
onlineUsers().length
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -40,16 +44,17 @@
<!-- Text Channels --> <!-- Text Channels -->
<div class="p-3"> <div class="p-3">
<div class="flex items-center justify-between mb-2 px-1"> <div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium"> <h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
Text Channels
</h4>
@if (canManageChannels()) { @if (canManageChannels()) {
<button <button
(click)="createChannel('text')" (click)="createChannel('text')"
class="text-muted-foreground hover:text-foreground transition-colors" class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Text Channel" title="Create Text Channel"
> >
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" /> <ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
</button> </button>
} }
</div> </div>
@@ -89,16 +94,17 @@
<!-- Voice Channels --> <!-- Voice Channels -->
<div class="p-3 pt-0"> <div class="p-3 pt-0">
<div class="flex items-center justify-between mb-2 px-1"> <div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium"> <h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
Voice Channels
</h4>
@if (canManageChannels()) { @if (canManageChannels()) {
<button <button
(click)="createChannel('voice')" (click)="createChannel('voice')"
class="text-muted-foreground hover:text-foreground transition-colors" class="text-muted-foreground hover:text-foreground transition-colors"
title="Create Voice Channel" title="Create Voice Channel"
> >
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" /> <ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
</button> </button>
} }
</div> </div>
@@ -116,7 +122,10 @@
[disabled]="!voiceEnabled()" [disabled]="!voiceEnabled()"
> >
<span class="flex items-center gap-2 text-foreground/80"> <span class="flex items-center gap-2 text-foreground/80">
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" /> <ng-icon
name="lucideMic"
class="w-4 h-4 text-muted-foreground"
/>
@if (renamingChannelId() === ch.id) { @if (renamingChannelId() === ch.id) {
<input <input
#renameInput #renameInput
@@ -155,17 +164,13 @@
: 'ring-2 ring-green-500/40' : 'ring-2 ring-green-500/40'
" "
/> />
<span class="text-sm text-foreground/80 truncate flex-1">{{ <span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
u.displayName
}}</span>
<!-- Ping latency indicator --> <!-- Ping latency indicator -->
@if (u.id !== currentUser()?.id) { @if (u.id !== currentUser()?.id) {
<span <span
class="w-2 h-2 rounded-full shrink-0" class="w-2 h-2 rounded-full shrink-0"
[class]="getPingColorClass(u)" [class]="getPingColorClass(u)"
[title]=" [title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'
"
></span> ></span>
} }
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) { @if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
@@ -177,7 +182,10 @@
</button> </button>
} }
@if (u.voiceState?.isMuted) { @if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" /> <ng-icon
name="lucideMicOff"
class="w-4 h-4 text-muted-foreground"
/>
} }
</div> </div>
} }
@@ -196,9 +204,7 @@
<!-- Current User (You) --> <!-- Current User (You) -->
@if (currentUser()) { @if (currentUser()) {
<div class="mb-4"> <div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1"> <h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
You
</h4>
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30"> <div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
<div class="relative"> <div class="relative">
<app-user-avatar <app-user-avatar
@@ -206,27 +212,26 @@
[avatarUrl]="currentUser()?.avatarUrl" [avatarUrl]="currentUser()?.avatarUrl"
size="sm" size="sm"
/> />
<span <span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
></span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p> <p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if (currentUser()?.voiceState?.isConnected) { @if (currentUser()?.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1"> <p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon name="lucideMic" class="w-2.5 h-2.5" /> <ng-icon
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice In voice
</p> </p>
} }
@if ( @if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
currentUser()?.screenShareState?.isSharing || <span class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse">
(currentUser()?.id && isUserSharing(currentUser()!.id)) <ng-icon
) { name="lucideMonitor"
<span class="w-2.5 h-2.5"
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse" />
>
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
LIVE LIVE
</span> </span>
} }
@@ -239,9 +244,7 @@
<!-- Other Online Users --> <!-- Other Online Users -->
@if (onlineUsersFiltered().length > 0) { @if (onlineUsersFiltered().length > 0) {
<div class="mb-4"> <div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1"> <h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineUsersFiltered().length }}</h4>
Online — {{ onlineUsersFiltered().length }}
</h4>
<div class="space-y-1"> <div class="space-y-1">
@for (user of onlineUsersFiltered(); track user.id) { @for (user of onlineUsersFiltered(); track user.id) {
<div <div
@@ -254,34 +257,26 @@
[avatarUrl]="user.avatarUrl" [avatarUrl]="user.avatarUrl"
size="sm" size="sm"
/> />
<span <span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
></span>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p> <p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') { @if (user.role === 'host') {
<span <span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium"
>Owner</span
>
} @else if (user.role === 'admin') { } @else if (user.role === 'admin') {
<span <span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium"
>Admin</span
>
} @else if (user.role === 'moderator') { } @else if (user.role === 'moderator') {
<span <span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium"
>Mod</span
>
} }
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) { @if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1"> <p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon name="lucideMic" class="w-2.5 h-2.5" /> <ng-icon
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice In voice
</p> </p>
} }
@@ -290,7 +285,10 @@
(click)="viewStream(user.id); $event.stopPropagation()" (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" 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"
> >
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" /> <ng-icon
name="lucideMonitor"
class="w-2.5 h-2.5"
/>
LIVE LIVE
</button> </button>
} }
@@ -327,42 +325,81 @@
(closed)="closeChannelMenu()" (closed)="closeChannelMenu()"
[width]="'w-44'" [width]="'w-44'"
> >
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button> <button
(click)="resyncMessages()"
class="context-menu-item"
>
Resync Messages
</button>
@if (canManageChannels()) { @if (canManageChannels()) {
<div class="context-menu-divider"></div> <div class="context-menu-divider"></div>
<button (click)="startRename()" class="context-menu-item">Rename Channel</button> <button
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button> (click)="startRename()"
class="context-menu-item"
>
Rename Channel
</button>
<button
(click)="deleteChannel()"
class="context-menu-item-danger"
>
Delete Channel
</button>
} }
</app-context-menu> </app-context-menu>
} }
<!-- User context menu (kick / role management) --> <!-- User context menu (kick / role management) -->
@if (showUserMenu()) { @if (showUserMenu()) {
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()"> <app-context-menu
[x]="userMenuX()"
[y]="userMenuY()"
(closed)="closeUserMenu()"
>
@if (isAdmin()) { @if (isAdmin()) {
@if (contextMenuUser()?.role === 'member') { @if (contextMenuUser()?.role === 'member') {
<button (click)="changeUserRole('moderator')" class="context-menu-item"> <button
(click)="changeUserRole('moderator')"
class="context-menu-item"
>
Promote to Moderator Promote to Moderator
</button> </button>
<button (click)="changeUserRole('admin')" class="context-menu-item"> <button
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin Promote to Admin
</button> </button>
} }
@if (contextMenuUser()?.role === 'moderator') { @if (contextMenuUser()?.role === 'moderator') {
<button (click)="changeUserRole('admin')" class="context-menu-item"> <button
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin Promote to Admin
</button> </button>
<button (click)="changeUserRole('member')" class="context-menu-item"> <button
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member Demote to Member
</button> </button>
} }
@if (contextMenuUser()?.role === 'admin') { @if (contextMenuUser()?.role === 'admin') {
<button (click)="changeUserRole('member')" class="context-menu-item"> <button
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member Demote to Member
</button> </button>
} }
<div class="context-menu-divider"></div> <div class="context-menu-divider"></div>
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button> <button
(click)="kickUserAction()"
class="context-menu-item-danger"
>
Kick User
</button>
} @else { } @else {
<div class="context-menu-empty">No actions available</div> <div class="context-menu-empty">No actions available</div>
} }

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal } from '@angular/core'; import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; 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 { VoiceSessionService } from '../../../core/services/voice-session.service';
import { VoiceActivityService } from '../../../core/services/voice-activity.service'; import { VoiceActivityService } from '../../../core/services/voice-activity.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import {
import { Channel, User } from '../../../core/models'; ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
} from '../../../shared';
import {
Channel,
ChatEvent,
Room,
User
} from '../../../core/models';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users'; type TabView = 'channels' | 'users';
@@ -41,15 +54,7 @@ type TabView = 'channels' | 'users';
@Component({ @Component({
selector: 'app-rooms-side-panel', selector: 'app-rooms-side-panel',
standalone: true, standalone: true,
imports: [ imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
CommonModule,
FormsModule,
NgIcon,
VoiceControlsComponent,
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideMessageSquare, lucideMessageSquare,
@@ -110,9 +115,7 @@ export class RoomsSidePanelComponent {
const currentId = current?.id; const currentId = current?.id;
const currentOderId = current?.oderId; const currentOderId = current?.oderId;
return this.onlineUsers().filter( return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
(user) => user.id !== currentId && user.oderId !== currentOderId
);
} }
/** Check whether the current user has permission to manage channels. */ /** Check whether the current user has permission to manage channels. */
@@ -218,12 +221,14 @@ export class RoomsSidePanelComponent {
const peers = this.webrtc.getConnectedPeers(); const peers = this.webrtc.getConnectedPeers();
if (peers.length === 0) { if (peers.length === 0) {
// No connected peers sync will time out // No connected peers - sync will time out
} }
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
peers.forEach((pid) => { peers.forEach((pid) => {
try { try {
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any); this.webrtc.sendToPeer(pid, inventoryRequest);
} catch (_error) { } catch (_error) {
// Failed to send inventory request to this peer // Failed to send inventory request to this peer
} }
@@ -327,6 +332,9 @@ export class RoomsSidePanelComponent {
return; return;
} }
if (!room)
return;
const current = this.currentUser(); const current = this.currentUser();
// Check if already connected to voice in a DIFFERENT server - must disconnect first // 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. // clear it so the user can join.
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.webrtc.isVoiceConnected()) { 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) { if (current.id) {
this.store.dispatch( this.store.dispatch(
UsersActions.updateVoiceState({ UsersActions.updateVoiceState({
@@ -356,67 +364,76 @@ export class RoomsSidePanelComponent {
} }
// If switching channels within the same server, just update the room // If switching channels within the same server, just update the room
const isSwitchingChannels = const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
current?.voiceState?.isConnected &&
current.voiceState.serverId === room?.id &&
current.voiceState.roomId !== roomId;
// Enable microphone and broadcast voice-state // Enable microphone and broadcast voice-state
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
enableVoicePromise enableVoicePromise
.then(() => { .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
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}`
});
}
})
.catch((_error) => { .catch((_error) => {
// Failed to join voice room // 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. */ /** Leave a voice channel and broadcast the disconnect state. */
leaveVoice(roomId: string) { leaveVoice(roomId: string) {
const current = this.currentUser(); const current = this.currentUser();
@@ -470,12 +487,8 @@ export class RoomsSidePanelComponent {
const users = this.onlineUsers(); const users = this.onlineUsers();
const room = this.currentRoom(); const room = this.currentRoom();
return users.filter( return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id)
(user) => .length;
!!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. */ /** Dispatch a viewer:focus event to display a remote user's screen share. */
@@ -500,9 +513,7 @@ export class RoomsSidePanelComponent {
return this.webrtc.isScreenSharing(); return this.webrtc.isScreenSharing();
} }
const user = this.onlineUsers().find( const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
);
if (user?.screenShareState?.isSharing === false) { if (user?.screenShareState?.isSharing === false) {
return false; return false;
@@ -518,10 +529,7 @@ export class RoomsSidePanelComponent {
const room = this.currentRoom(); const room = this.currentRoom();
return this.onlineUsers().filter( return this.onlineUsers().filter(
(user) => (user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
); );
} }
@@ -530,11 +538,7 @@ export class RoomsSidePanelComponent {
const me = this.currentUser(); const me = this.currentUser();
const room = this.currentRoom(); const room = this.currentRoom();
return !!( return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id
);
} }
/** Check whether voice is enabled by the current room's permissions. */ /** 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. * Return a Tailwind `bg-*` class representing the latency quality.
* - green : < 100 ms * - green : < 100 ms
* - yellow : 100199 ms * - yellow : 100-199 ms
* - orange : 200349 ms * - orange : 200-349 ms
* - red : >= 350 ms * - red : >= 350 ms
* - gray : no data yet * - gray : no data yet
*/ */

View File

@@ -40,7 +40,10 @@
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors" class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
title="Settings" title="Settings"
> >
<ng-icon name="lucideSettings" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideSettings"
class="w-5 h-5 text-muted-foreground"
/>
</button> </button>
</div> </div>
</div> </div>
@@ -52,7 +55,10 @@
type="button" type="button"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors" class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
> >
<ng-icon name="lucidePlus" class="w-4 h-4" /> <ng-icon
name="lucidePlus"
class="w-4 h-4"
/>
Create New Server Create New Server
</button> </button>
</div> </div>
@@ -65,7 +71,10 @@
</div> </div>
} @else if (searchResults().length === 0) { } @else if (searchResults().length === 0) {
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground"> <div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ng-icon name="lucideSearch" class="w-12 h-12 mb-4 opacity-50" /> <ng-icon
name="lucideSearch"
class="w-12 h-12 mb-4 opacity-50"
/>
<p class="text-lg">No servers found</p> <p class="text-lg">No servers found</p>
<p class="text-sm">Try a different search or create your own</p> <p class="text-sm">Try a different search or create your own</p>
</div> </div>
@@ -84,9 +93,15 @@
{{ server.name }} {{ server.name }}
</h3> </h3>
@if (server.isPrivate) { @if (server.isPrivate) {
<ng-icon name="lucideLock" class="w-4 h-4 text-muted-foreground" /> <ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
} @else { } @else {
<ng-icon name="lucideGlobe" class="w-4 h-4 text-muted-foreground" /> <ng-icon
name="lucideGlobe"
class="w-4 h-4 text-muted-foreground"
/>
} }
</div> </div>
@if (server.description) { @if (server.description) {
@@ -101,13 +116,14 @@
} }
</div> </div>
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4"> <div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
<ng-icon name="lucideUsers" class="w-4 h-4" /> <ng-icon
name="lucideUsers"
class="w-4 h-4"
/>
<span>{{ server.userCount }}/{{ server.maxUsers }}</span> <span>{{ server.userCount }}/{{ server.maxUsers }}</span>
</div> </div>
</div> </div>
<div class="mt-2 text-xs text-muted-foreground"> <div class="mt-2 text-xs text-muted-foreground">Hosted by {{ server.hostName }}</div>
Hosted by {{ server.hostName }}
</div>
</button> </button>
} }
</div> </div>
@@ -145,7 +161,11 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="create-server-name" class="block text-sm font-medium text-foreground mb-1">Server Name</label> <label
for="create-server-name"
class="block text-sm font-medium text-foreground mb-1"
>Server Name</label
>
<input <input
type="text" type="text"
[(ngModel)]="newServerName" [(ngModel)]="newServerName"
@@ -156,7 +176,11 @@
</div> </div>
<div> <div>
<label for="create-server-description" class="block text-sm font-medium text-foreground mb-1">Description (optional)</label> <label
for="create-server-description"
class="block text-sm font-medium text-foreground mb-1"
>Description (optional)</label
>
<textarea <textarea
[(ngModel)]="newServerDescription" [(ngModel)]="newServerDescription"
placeholder="What's your server about?" placeholder="What's your server about?"
@@ -167,7 +191,11 @@
</div> </div>
<div> <div>
<label for="create-server-topic" class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label> <label
for="create-server-topic"
class="block text-sm font-medium text-foreground mb-1"
>Topic (optional)</label
>
<input <input
type="text" type="text"
[(ngModel)]="newServerTopic" [(ngModel)]="newServerTopic"
@@ -184,12 +212,20 @@
id="private" id="private"
class="w-4 h-4 rounded border-border bg-secondary" class="w-4 h-4 rounded border-border bg-secondary"
/> />
<label for="private" class="text-sm text-foreground">Private server</label> <label
for="private"
class="text-sm text-foreground"
>Private server</label
>
</div> </div>
@if (newServerPrivate()) { @if (newServerPrivate()) {
<div> <div>
<label for="create-server-password" class="block text-sm font-medium text-foreground mb-1">Password</label> <label
for="create-server-password"
class="block text-sm font-medium text-foreground mb-1"
>Password</label
>
<input <input
type="password" type="password"
[(ngModel)]="newServerPassword" [(ngModel)]="newServerPassword"

View File

@@ -1,10 +1,19 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, OnInit, signal } from '@angular/core'; import {
Component,
inject,
OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; import {
debounceTime,
distinctUntilChanged,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideSearch, lucideSearch,
@@ -147,16 +156,20 @@ export class ServerSearchComponent implements OnInit {
/** Join a previously saved room by converting it to a ServerInfo payload. */ /** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
this.joinServer({ this.joinServer(this.toServerInfo(room));
}
private toServerInfo(room: Room): ServerInfo {
return {
id: room.id, id: room.id,
name: room.name, name: room.name,
description: room.description, description: room.description,
hostName: room.hostId || 'Unknown', hostName: room.hostId || 'Unknown',
userCount: room.userCount, userCount: room.userCount,
maxUsers: room.maxUsers || 50, maxUsers: room.maxUsers ?? 50,
isPrivate: !!room.password, isPrivate: !!room.password,
createdAt: room.createdAt createdAt: room.createdAt
} as any); };
} }
private resetCreateForm(): void { private resetCreateForm(): void {

View File

@@ -1,24 +1,33 @@
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative"> <nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
<!-- Create button --> <!-- Create button -->
<button <button
type="button"
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
title="Create Server" title="Create Server"
(click)="createServer()" (click)="createServer()"
> >
<ng-icon name="lucidePlus" class="w-5 h-5" /> <ng-icon
name="lucidePlus"
class="w-5 h-5"
/>
</button> </button>
<!-- Saved servers icons --> <!-- Saved servers icons -->
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2"> <div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
@for (room of savedRooms(); track room.id) { @for (room of savedRooms(); track room.id) {
<button <button
type="button"
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all" class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
[title]="room.name" [title]="room.name"
(click)="joinSavedRoom(room)" (click)="joinSavedRoom(room)"
(contextmenu)="openContextMenu($event, room)" (contextmenu)="openContextMenu($event, room)"
> >
@if (room.icon) { @if (room.icon) {
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" /> <img
[ngSrc]="room.icon"
[alt]="room.name"
class="w-full h-full object-cover"
/>
} @else { } @else {
<div class="w-full h-full flex items-center justify-center bg-secondary"> <div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span> <span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
@@ -31,11 +40,28 @@
<!-- Context menu --> <!-- Context menu -->
@if (showMenu()) { @if (showMenu()) {
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'"> <app-context-menu
[x]="menuX()"
[y]="menuY()"
(closed)="closeMenu()"
[width]="'w-44'"
>
@if (isCurrentContextRoom()) { @if (isCurrentContextRoom()) {
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button> <button
type="button"
(click)="leaveServer()"
class="context-menu-item"
>
Leave Server
</button>
} }
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button> <button
type="button"
(click)="openForgetConfirm()"
class="context-menu-item"
>
Forget Server
</button>
</app-context-menu> </app-context-menu>
} }
@@ -48,6 +74,8 @@
(cancelled)="cancelForget()" (cancelled)="cancelForget()"
[widthClass]="'w-[280px]'" [widthClass]="'w-[280px]'"
> >
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p> <p>
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
</p>
</app-confirm-dialog> </app-confirm-dialog>
} }

View File

@@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import {
import { CommonModule } from '@angular/common'; Component,
inject,
signal
} from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -16,7 +20,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
@Component({ @Component({
selector: 'app-servers-rail', selector: 'app-servers-rail',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent], imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent, NgOptimizedImage],
viewProviders: [provideIcons({ lucidePlus })], viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html' templateUrl: './servers-rail.component.html'
}) })
@@ -92,14 +96,16 @@ export class ServersRailComponent {
this.store.dispatch(RoomsActions.viewServer({ room })); this.store.dispatch(RoomsActions.viewServer({ room }));
} else { } else {
// First time joining this server // First time joining this server
this.store.dispatch(RoomsActions.joinRoom({ this.store.dispatch(
roomId: room.id, RoomsActions.joinRoom({
serverInfo: { roomId: room.id,
name: room.name, serverInfo: {
description: room.description, name: room.name,
hostName: room.hostId || 'Unknown' description: room.description,
} hostName: room.hostId || 'Unknown'
})); }
})
);
} }
} }
@@ -108,7 +114,7 @@ export class ServersRailComponent {
evt.preventDefault(); evt.preventDefault();
this.contextRoom.set(room); this.contextRoom.set(room);
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px) // Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
this.menuX.set(Math.max((evt.clientX + 8), 72)); this.menuX.set(Math.max(evt.clientX + 8, 72));
this.menuY.set(evt.clientY); this.menuY.set(evt.clientY);
this.showMenu.set(true); this.showMenu.set(true);
} }
@@ -161,5 +167,4 @@ export class ServersRailComponent {
cancelForget(): void { cancelForget(): void {
this.showConfirm.set(false); this.showConfirm.set(false);
} }
} }

View File

@@ -5,9 +5,7 @@
} @else { } @else {
@for (ban of bannedUsers(); track ban.oderId) { @for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<div <div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm"
>
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }} {{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -18,19 +16,21 @@
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p> <p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
} }
@if (ban.expiresAt) { @if (ban.expiresAt) {
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
Expires: {{ formatExpiry(ban.expiresAt) }}
</p>
} @else { } @else {
<p class="text-xs text-destructive">Permanent</p> <p class="text-xs text-destructive">Permanent</p>
} }
</div> </div>
@if (isAdmin()) { @if (isAdmin()) {
<button <button
type="button"
(click)="unbanUser(ban)" (click)="unbanUser(ban)"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground" class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
> >
<ng-icon name="lucideX" class="w-4 h-4" /> <ng-icon
name="lucideX"
class="w-4 h-4"
/>
</button> </button>
} }
</div> </div>
@@ -38,7 +38,5 @@
} }
</div> </div>
} @else { } @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm"> <div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
Select a server from the sidebar to manage
</div>
} }

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input } from '@angular/core'; import {
Component,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -40,7 +44,8 @@ export class BansSettingsComponent {
return ( return (
date.toLocaleDateString() + date.toLocaleDateString() +
' ' + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' })
); );
} }
} }

View File

@@ -5,24 +5,21 @@
} @else { } @else {
@for (user of membersFiltered(); track user.id) { @for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar [name]="user.displayName || '?'" size="sm" /> <app-user-avatar
[name]="user.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate"> <p class="text-sm font-medium text-foreground truncate">
{{ user.displayName }} {{ user.displayName }}
</p> </p>
@if (user.role === 'host') { @if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded" <span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
>Owner</span
>
} @else if (user.role === 'admin') { } @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded" <span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
>Admin</span
>
} @else if (user.role === 'moderator') { } @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded" <span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
>Mod</span
>
} }
</div> </div>
</div> </div>
@@ -42,14 +39,20 @@
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors" class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick" title="Kick"
> >
<ng-icon name="lucideUserX" class="w-4 h-4" /> <ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button> </button>
<button <button
(click)="banMember(user)" (click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors" class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban" title="Ban"
> >
<ng-icon name="lucideBan" class="w-4 h-4" /> <ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button> </button>
</div> </div>
} }
@@ -58,7 +61,5 @@
} }
</div> </div>
} @else { } @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm"> <div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
Select a server from the sidebar to manage
</div>
} }

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input } from '@angular/core'; import {
Component,
inject,
input
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -15,7 +19,12 @@ import { UserAvatarComponent } from '../../../../shared';
@Component({ @Component({
selector: 'app-members-settings', selector: 'app-members-settings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent], imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideUserX, lucideUserX,
@@ -43,7 +52,9 @@ export class MembersSettingsComponent {
} }
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void { changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtcService.broadcastMessage({ this.webrtcService.broadcastMessage({
type: 'role-change', type: 'role-change',
targetUserId: user.id, targetUserId: user.id,

View File

@@ -3,7 +3,10 @@
<section> <section>
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4> <h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
</div> </div>
<button <button
@@ -11,14 +14,16 @@
[disabled]="isTesting()" [disabled]="isTesting()"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50" class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
> >
<ng-icon name="lucideRefreshCw" class="w-3.5 h-3.5" [class.animate-spin]="isTesting()" /> <ng-icon
name="lucideRefreshCw"
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All Test All
</button> </button>
</div> </div>
<p class="text-xs text-muted-foreground mb-3"> <p class="text-xs text-muted-foreground mb-3">Server directories to search for rooms. The active server is used for creating new rooms.</p>
Server directories to search for rooms. The active server is used for creating new rooms.
</p>
<!-- Server List --> <!-- Server List -->
<div class="space-y-2 mb-3"> <div class="space-y-2 mb-3">
@@ -41,10 +46,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span> <span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) { @if (server.isActive) {
<span <span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full"
>Active</span
>
} }
</div> </div>
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p> <p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
@@ -105,7 +107,10 @@
[disabled]="!newServerName || !newServerUrl" [disabled]="!newServerName || !newServerUrl"
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end" class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
> >
<ng-icon name="lucidePlus" class="w-4 h-4" /> <ng-icon
name="lucidePlus"
class="w-4 h-4"
/>
</button> </button>
</div> </div>
@if (addError()) { @if (addError()) {
@@ -117,7 +122,10 @@
<!-- Connection Settings --> <!-- Connection Settings -->
<section> <section>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Connection</h4> <h4 class="text-sm font-semibold text-foreground">Connection</h4>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -18,7 +22,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
@Component({ @Component({
selector: 'app-network-settings', selector: 'app-network-settings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideGlobe, lucideGlobe,
@@ -65,6 +73,7 @@ export class NetworkSettingsComponent {
name: this.newServerName.trim(), name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, '') url: this.newServerUrl.trim().replace(/\/$/, '')
}); });
this.newServerName = ''; this.newServerName = '';
this.newServerUrl = ''; this.newServerUrl = '';
const servers = this.servers(); const servers = this.servers();
@@ -108,6 +117,7 @@ export class NetworkSettingsComponent {
searchAllServers: this.searchAllServers searchAllServers: this.searchAllServers
}) })
); );
this.serverDirectory.setSearchAllServers(this.searchAllServers); this.serverDirectory.setSearchAllServers(this.searchAllServers);
} }
} }

View File

@@ -1,9 +1,7 @@
@if (server()) { @if (server()) {
<div class="space-y-4 max-w-xl"> <div class="space-y-4 max-w-xl">
@if (!isAdmin()) { @if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-1"> <p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
You are viewing this server's permissions. Only the server owner can make changes.
</p>
} }
<div class="space-y-2.5"> <div class="space-y-2.5">
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
@@ -118,13 +116,14 @@
[class.bg-green-600]="saveSuccess() === 'permissions'" [class.bg-green-600]="saveSuccess() === 'permissions'"
[class.hover:bg-green-600]="saveSuccess() === 'permissions'" [class.hover:bg-green-600]="saveSuccess() === 'permissions'"
> >
<ng-icon name="lucideCheck" class="w-4 h-4" /> <ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }} {{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
</button> </button>
} }
</div> </div>
} @else { } @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm"> <div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
Select a server from the sidebar to manage
</div>
} }

View File

@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input, signal } from '@angular/core'; import {
Component,
inject,
input,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -12,7 +17,11 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@Component({ @Component({
selector: 'app-permissions-settings', selector: 'app-permissions-settings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck lucideCheck
@@ -75,6 +84,7 @@ export class PermissionsSettingsComponent {
} }
}) })
); );
this.showSaveSuccess('permissions'); this.showSaveSuccess('permissions');
} }

View File

@@ -4,13 +4,16 @@
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4> <h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
@if (!isAdmin()) { @if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3"> <p class="text-xs text-muted-foreground mb-3">
You are viewing this server's settings as a non-admin. Only the server owner can make You are viewing this server's settings as a non-admin. Only the server owner can make changes.
changes.
</p> </p>
} }
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="room-name" class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label> <label
for="room-name"
class="block text-xs font-medium text-muted-foreground mb-1"
>Room Name</label
>
<input <input
type="text" type="text"
[(ngModel)]="roomName" [(ngModel)]="roomName"
@@ -22,7 +25,11 @@
/> />
</div> </div>
<div> <div>
<label for="room-description" class="block text-xs font-medium text-muted-foreground mb-1">Description</label> <label
for="room-description"
class="block text-xs font-medium text-muted-foreground mb-1"
>Description</label
>
<textarea <textarea
[(ngModel)]="roomDescription" [(ngModel)]="roomDescription"
[readOnly]="!isAdmin()" [readOnly]="!isAdmin()"
@@ -49,9 +56,15 @@
[class.text-muted-foreground]="!isPrivate()" [class.text-muted-foreground]="!isPrivate()"
> >
@if (isPrivate()) { @if (isPrivate()) {
<ng-icon name="lucideLock" class="w-4 h-4" /> <ng-icon
name="lucideLock"
class="w-4 h-4"
/>
} @else { } @else {
<ng-icon name="lucideUnlock" class="w-4 h-4" /> <ng-icon
name="lucideUnlock"
class="w-4 h-4"
/>
} }
</button> </button>
</div> </div>
@@ -65,7 +78,10 @@
</div> </div>
} }
<div> <div>
<label for="room-max-users" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="room-max-users"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Max Users (0 = unlimited) Max Users (0 = unlimited)
</label> </label>
<input <input
@@ -90,7 +106,10 @@
[class.bg-green-600]="saveSuccess() === 'server'" [class.bg-green-600]="saveSuccess() === 'server'"
[class.hover:bg-green-600]="saveSuccess() === 'server'" [class.hover:bg-green-600]="saveSuccess() === 'server'"
> >
<ng-icon name="lucideCheck" class="w-4 h-4" /> <ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }} {{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button> </button>
@@ -102,7 +121,10 @@
type="button" type="button"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm" class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
> >
<ng-icon name="lucideTrash2" class="w-4 h-4" /> <ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room Delete Room
</button> </button>
</div> </div>
@@ -123,7 +145,5 @@
</app-confirm-dialog> </app-confirm-dialog>
} }
} @else { } @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm"> <div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
Select a server from the sidebar to manage
</div>
} }

View File

@@ -1,10 +1,21 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, input, signal, computed } from '@angular/core'; import {
Component,
inject,
input,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { lucideCheck, lucideTrash2, lucideLock, lucideUnlock } from '@ng-icons/lucide'; import {
lucideCheck,
lucideTrash2,
lucideLock,
lucideUnlock
} from '@ng-icons/lucide';
import { Room } from '../../../../core/models'; import { Room } from '../../../../core/models';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@@ -14,7 +25,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
@Component({ @Component({
selector: 'app-server-settings', selector: 'app-server-settings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent], imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck, lucideCheck,
@@ -78,6 +94,7 @@ export class ServerSettingsComponent {
} }
}) })
); );
this.showSaveSuccess('server'); this.showSaveSuccess('server');
} }

View File

@@ -35,11 +35,7 @@
<div class="flex-1 overflow-y-auto py-2"> <div class="flex-1 overflow-y-auto py-2">
<!-- Global section --> <!-- Global section -->
<p <p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">General</p>
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
>
General
</p>
@for (page of globalPages; track page.id) { @for (page of globalPages; track page.id) {
<button <button
(click)="navigate(page.id)" (click)="navigate(page.id)"
@@ -51,7 +47,10 @@
[class.text-foreground]="activePage() !== page.id" [class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id" [class.hover:bg-secondary]="activePage() !== page.id"
> >
<ng-icon [name]="page.icon" class="w-4 h-4" /> <ng-icon
[name]="page.icon"
class="w-4 h-4"
/>
{{ page.label }} {{ page.label }}
</button> </button>
} }
@@ -59,11 +58,7 @@
<!-- Server section --> <!-- Server section -->
@if (savedRooms().length > 0) { @if (savedRooms().length > 0) {
<div class="mt-3 pt-3 border-t border-border"> <div class="mt-3 pt-3 border-t border-border">
<p <p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
>
Server
</p>
<!-- Server selector --> <!-- Server selector -->
<div class="px-3 pb-2"> <div class="px-3 pb-2">
@@ -91,7 +86,10 @@
[class.text-foreground]="activePage() !== page.id" [class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id" [class.hover:bg-secondary]="activePage() !== page.id"
> >
<ng-icon [name]="page.icon" class="w-4 h-4" /> <ng-icon
[name]="page.icon"
class="w-4 h-4"
/>
{{ page.label }} {{ page.label }}
</button> </button>
} }
@@ -104,9 +102,7 @@
<!-- Content --> <!-- Content -->
<div class="flex-1 flex flex-col min-w-0"> <div class="flex-1 flex flex-col min-w-0">
<!-- Header --> <!-- Header -->
<div <div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"
>
<h3 class="text-lg font-semibold text-foreground"> <h3 class="text-lg font-semibold text-foreground">
@switch (activePage()) { @switch (activePage()) {
@case ('network') { @case ('network') {
@@ -134,7 +130,10 @@
type="button" type="button"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground" class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
> >
<ng-icon name="lucideX" class="w-5 h-5" /> <ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button> </button>
</div> </div>

View File

@@ -24,9 +24,7 @@ import {
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { import { selectCurrentUser } from '../../../store/users/users.selectors';
selectCurrentUser
} from '../../../store/users/users.selectors';
import { Room } from '../../../core/models'; import { Room } from '../../../core/models';
import { NetworkSettingsComponent } from './network-settings/network-settings.component'; import { NetworkSettingsComponent } from './network-settings/network-settings.component';
@@ -80,14 +78,25 @@ export class SettingsModalComponent {
// --- Side-nav items --- // --- Side-nav items ---
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'network', label: 'Network', icon: 'lucideGlobe' }, { id: 'network',
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' } label: 'Network',
icon: 'lucideGlobe' }, { id: 'voice',
label: 'Voice & Audio',
icon: 'lucideAudioLines' }
]; ];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server', label: 'Server', icon: 'lucideSettings' }, { id: 'server',
{ id: 'members', label: 'Members', icon: 'lucideUsers' }, label: 'Server',
{ id: 'bans', label: 'Bans', icon: 'lucideBan' }, icon: 'lucideSettings' },
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' } { id: 'members',
label: 'Members',
icon: 'lucideUsers' },
{ id: 'bans',
label: 'Bans',
icon: 'lucideBan' },
{ id: 'permissions',
label: 'Permissions',
icon: 'lucideShield' }
]; ];
// ===== SERVER SELECTOR ===== // ===== SERVER SELECTOR =====

View File

@@ -2,12 +2,19 @@
<!-- Devices --> <!-- Devices -->
<section> <section>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideMic" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideMic"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Devices</h4> <h4 class="text-sm font-semibold text-foreground">Devices</h4>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label for="input-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label> <label
for="input-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Microphone</label
>
<select <select
(change)="onInputDeviceChange($event)" (change)="onInputDeviceChange($event)"
id="input-device-select" id="input-device-select"
@@ -24,7 +31,11 @@
</select> </select>
</div> </div>
<div> <div>
<label for="output-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label> <label
for="output-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Speaker</label
>
<select <select
(change)="onOutputDeviceChange($event)" (change)="onOutputDeviceChange($event)"
id="output-device-select" id="output-device-select"
@@ -46,12 +57,18 @@
<!-- Volume --> <!-- Volume -->
<section> <section>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideHeadphones" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideHeadphones"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Volume</h4> <h4 class="text-sm font-semibold text-foreground">Volume</h4>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label for="input-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="input-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Input Volume: {{ inputVolume() }}% Input Volume: {{ inputVolume() }}%
</label> </label>
<input <input
@@ -65,7 +82,10 @@
/> />
</div> </div>
<div> <div>
<label for="output-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="output-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Output Volume: {{ outputVolume() }}% Output Volume: {{ outputVolume() }}%
</label> </label>
<input <input
@@ -79,7 +99,10 @@
/> />
</div> </div>
<div> <div>
<label for="notification-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="notification-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}% Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -102,9 +125,7 @@
Test Test
</button> </button>
</div> </div>
<p class="text-[10px] text-muted-foreground/60 mt-1"> <p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave &amp; notification sounds</p>
Controls join, leave &amp; notification sounds
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -112,24 +133,49 @@
<!-- Quality & Processing --> <!-- Quality & Processing -->
<section> <section>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4> <h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label for="latency-profile-select" class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label> <label
for="latency-profile-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Latency Profile</label
>
<select <select
(change)="onLatencyProfileChange($event)" (change)="onLatencyProfileChange($event)"
id="latency-profile-select" id="latency-profile-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option> <option
<option value="balanced" [selected]="latencyProfile() === 'balanced'">Balanced</option> value="low"
<option value="high" [selected]="latencyProfile() === 'high'">High (quality)</option> [selected]="latencyProfile() === 'low'"
>
Low (fast)
</option>
<option
value="balanced"
[selected]="latencyProfile() === 'balanced'"
>
Balanced
</option>
<option
value="high"
[selected]="latencyProfile() === 'high'"
>
High (quality)
</option>
</select> </select>
</div> </div>
<div> <div>
<label for="audio-bitrate-slider" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="audio-bitrate-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Audio Bitrate: {{ audioBitrate() }} kbps Audio Bitrate: {{ audioBitrate() }} kbps
</label> </label>
<input <input
@@ -187,7 +233,10 @@
<!-- Voice Leveling --> <!-- Voice Leveling -->
<section> <section>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<ng-icon name="lucideActivity" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideActivity"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Voice Leveling</h4> <h4 class="text-sm font-semibold text-foreground">Voice Leveling</h4>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -212,12 +261,15 @@
</label> </label>
</div> </div>
<!-- Advanced controls visible only when enabled --> <!-- Advanced controls - visible only when enabled -->
@if (voiceLeveling.enabled()) { @if (voiceLeveling.enabled()) {
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1"> <div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
<!-- Target Loudness --> <!-- Target Loudness -->
<div class="pl-3"> <div class="pl-3">
<label for="target-loudness-slider" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="target-loudness-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS
</label> </label>
<input <input
@@ -238,19 +290,32 @@
<!-- AGC Strength --> <!-- AGC Strength -->
<div class="pl-3"> <div class="pl-3">
<label for="agc-strength-select" class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label> <label
for="agc-strength-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>AGC Strength</label
>
<select <select
(change)="onStrengthChange($event)" (change)="onStrengthChange($event)"
id="agc-strength-select" id="agc-strength-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option value="low" [selected]="voiceLeveling.strength() === 'low'"> <option
value="low"
[selected]="voiceLeveling.strength() === 'low'"
>
Low (gentle) Low (gentle)
</option> </option>
<option value="medium" [selected]="voiceLeveling.strength() === 'medium'"> <option
value="medium"
[selected]="voiceLeveling.strength() === 'medium'"
>
Medium Medium
</option> </option>
<option value="high" [selected]="voiceLeveling.strength() === 'high'"> <option
value="high"
[selected]="voiceLeveling.strength() === 'high'"
>
High (aggressive) High (aggressive)
</option> </option>
</select> </select>
@@ -258,7 +323,10 @@
<!-- Max Gain Boost --> <!-- Max Gain Boost -->
<div class="pl-3"> <div class="pl-3">
<label for="max-gain-slider" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="max-gain-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB
</label> </label>
<input <input
@@ -279,7 +347,10 @@
<!-- Response Speed --> <!-- Response Speed -->
<div class="pl-3"> <div class="pl-3">
<label for="response-speed-select" class="block text-xs font-medium text-muted-foreground mb-1"> <label
for="response-speed-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Response Speed Response Speed
</label> </label>
<select <select
@@ -287,11 +358,22 @@
id="response-speed-select" id="response-speed-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option value="slow" [selected]="voiceLeveling.speed() === 'slow'"> <option
value="slow"
[selected]="voiceLeveling.speed() === 'slow'"
>
Slow (natural) Slow (natural)
</option> </option>
<option value="medium" [selected]="voiceLeveling.speed() === 'medium'">Medium</option> <option
<option value="fast" [selected]="voiceLeveling.speed() === 'fast'"> value="medium"
[selected]="voiceLeveling.speed() === 'medium'"
>
Medium
</option>
<option
value="fast"
[selected]="voiceLeveling.speed() === 'fast'"
>
Fast (aggressive) Fast (aggressive)
</option> </option>
</select> </select>

View File

@@ -1,16 +1,22 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal } from '@angular/core'; import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMic, lucideHeadphones, lucideAudioLines, lucideActivity } from '@ng-icons/lucide'; import {
lucideMic,
lucideHeadphones,
lucideAudioLines,
lucideActivity
} from '@ng-icons/lucide';
import { WebRTCService } from '../../../../core/services/webrtc.service'; import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service'; import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
import { import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
NotificationAudioService,
AppSound
} from '../../../../core/services/notification-audio.service';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants'; import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
interface AudioDevice { interface AudioDevice {
@@ -21,7 +27,11 @@ interface AudioDevice {
@Component({ @Component({
selector: 'app-voice-settings', selector: 'app-voice-settings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideMic, lucideMic,
@@ -63,12 +73,15 @@ export class VoiceSettingsComponent {
this.inputDevices.set( this.inputDevices.set(
devices devices
.filter((device) => device.kind === 'audioinput') .filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId, label: device.label })) .map((device) => ({ deviceId: device.deviceId,
label: device.label }))
); );
this.outputDevices.set( this.outputDevices.set(
devices devices
.filter((device) => device.kind === 'audiooutput') .filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId, label: device.label })) .map((device) => ({ deviceId: device.deviceId,
label: device.label }))
); );
} catch {} } catch {}
} }

View File

@@ -5,9 +5,15 @@
class="p-2 hover:bg-secondary rounded-lg transition-colors" class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Go back" title="Go back"
> >
<ng-icon name="lucideArrowLeft" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideArrowLeft"
class="w-5 h-5 text-muted-foreground"
/>
</button> </button>
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" /> <ng-icon
name="lucideSettings"
class="w-6 h-6 text-primary"
/>
<h1 class="text-2xl font-bold text-foreground">Settings</h1> <h1 class="text-2xl font-bold text-foreground">Settings</h1>
</div> </div>
@@ -15,7 +21,10 @@
<div class="bg-card border border-border rounded-lg p-6 mb-6"> <div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2> <h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
</div> </div>
<button <button
@@ -23,14 +32,18 @@
[disabled]="isTesting()" [disabled]="isTesting()"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50" class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
> >
<ng-icon name="lucideRefreshCw" class="w-4 h-4" [class.animate-spin]="isTesting()" /> <ng-icon
name="lucideRefreshCw"
class="w-4 h-4"
[class.animate-spin]="isTesting()"
/>
Test All Test All
</button> </button>
</div> </div>
<p class="text-sm text-muted-foreground mb-4"> <p class="text-sm text-muted-foreground mb-4">
Add multiple server directories to search for rooms across different networks. The active Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
server will be used for creating and registering new rooms. rooms.
</p> </p>
<!-- Server List --> <!-- Server List -->
@@ -58,9 +71,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ server.name }}</span> <span class="font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) { @if (server.isActive) {
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full" <span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
>Active</span
>
} }
</div> </div>
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p> <p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
@@ -123,7 +134,10 @@
[disabled]="!newServerName || !newServerUrl" [disabled]="!newServerName || !newServerUrl"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end" class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
> >
<ng-icon name="lucidePlus" class="w-5 h-5" /> <ng-icon
name="lucidePlus"
class="w-5 h-5"
/>
</button> </button>
</div> </div>
@if (addError()) { @if (addError()) {
@@ -135,7 +149,10 @@
<!-- Connection Settings --> <!-- Connection Settings -->
<div class="bg-card border border-border rounded-lg p-6 mb-6"> <div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2> <h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
</div> </div>
@@ -143,9 +160,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-foreground">Auto-reconnect</p> <p class="font-medium text-foreground">Auto-reconnect</p>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
Automatically reconnect when connection is lost
</p>
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input <input
@@ -163,9 +178,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-foreground">Search all servers</p> <p class="font-medium text-foreground">Search all servers</p>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">Search across all configured server directories</p>
Search across all configured server directories
</p>
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input <input
@@ -185,7 +198,10 @@
<!-- Voice Settings --> <!-- Voice Settings -->
<div class="bg-card border border-border rounded-lg p-6"> <div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2> <h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
</div> </div>
@@ -195,9 +211,7 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div> <div>
<p class="font-medium text-foreground">Notification volume</p> <p class="font-medium text-foreground">Notification volume</p>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
Volume for join, leave, and notification sounds
</p>
</div> </div>
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right"> <span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}% {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
@@ -226,9 +240,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-foreground">Noise reduction</p> <p class="font-medium text-foreground">Noise reduction</p>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
Use RNNoise to suppress background noise from your microphone
</p>
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input <input

View File

@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, signal, OnInit } from '@angular/core'; import {
Component,
inject,
signal,
OnInit
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -25,7 +30,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon], imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideServer, lucideServer,
@@ -142,6 +151,7 @@ export class SettingsComponent implements OnInit {
searchAllServers: this.searchAllServers searchAllServers: this.searchAllServers
}) })
); );
this.serverDirectory.setSearchAllServers(this.searchAllServers); this.serverDirectory.setSearchAllServers(this.searchAllServers);
} }
@@ -190,8 +200,10 @@ export class SettingsComponent implements OnInit {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY_VOICE_SETTINGS, STORAGE_KEY_VOICE_SETTINGS,
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction }) JSON.stringify({ ...existing,
noiseReduction: this.noiseReduction })
); );
await this.webrtcService.toggleNoiseReduction(this.noiseReduction); await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
} }
} }

View File

@@ -1,23 +1,45 @@
<div <div
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none" class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
style="-webkit-app-region: drag;" style="-webkit-app-region: drag"
> >
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;"> <div
class="flex items-center gap-2 min-w-0 relative"
style="-webkit-app-region: no-drag"
>
@if (inRoom()) { @if (inRoom()) {
<button type="button" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back"> <button
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" /> type="button"
(click)="onBack()"
class="p-2 hover:bg-secondary rounded"
title="Back"
>
<ng-icon
name="lucideChevronLeft"
class="w-5 h-5 text-muted-foreground"
/>
</button> </button>
} }
@if (inRoom()) { @if (inRoom()) {
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" /> <ng-icon
name="lucideHash"
class="w-5 h-5 text-muted-foreground"
/>
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span> <span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
@if (roomDescription()) { @if (roomDescription()) {
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate"> <span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
{{ roomDescription() }} {{ roomDescription() }}
</span> </span>
} }
<button type="button" (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu"> <button
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" /> type="button"
(click)="toggleMenu()"
class="ml-2 p-2 hover:bg-secondary rounded"
title="Menu"
>
<ng-icon
name="lucideMenu"
class="w-5 h-5 text-muted-foreground"
/>
</button> </button>
<!-- Anchored dropdown under the menu button --> <!-- Anchored dropdown under the menu button -->
@if (showMenu()) { @if (showMenu()) {
@@ -48,7 +70,10 @@
</div> </div>
} }
</div> </div>
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;"> <div
class="flex items-center gap-2"
style="-webkit-app-region: no-drag"
>
@if (!isAuthed()) { @if (!isAuthed()) {
<button <button
type="button" type="button"
@@ -60,14 +85,38 @@
</button> </button>
} }
@if (isElectron()) { @if (isElectron()) {
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()"> <button
<ng-icon name="lucideMinus" class="w-4 h-4" /> type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
title="Minimize"
(click)="minimize()"
>
<ng-icon
name="lucideMinus"
class="w-4 h-4"
/>
</button> </button>
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()"> <button
<ng-icon name="lucideSquare" class="w-4 h-4" /> type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
title="Maximize"
(click)="maximize()"
>
<ng-icon
name="lucideSquare"
class="w-4 h-4"
/>
</button> </button>
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()"> <button
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" /> type="button"
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
title="Close"
(click)="close()"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-destructive"
/>
</button> </button>
} }
</div> </div>
@@ -82,6 +131,6 @@
tabindex="0" tabindex="0"
role="button" role="button"
aria-label="Close menu overlay" aria-label="Close menu overlay"
style="-webkit-app-region: no-drag;" style="-webkit-app-region: no-drag"
></div> ></div>
} }

View File

@@ -1,9 +1,21 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Component, inject, computed, signal } from '@angular/core'; import {
Component,
inject,
computed,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide'; import {
lucideMinus,
lucideSquare,
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu
} from '@ng-icons/lucide';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
@@ -17,7 +29,14 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
selector: 'app-title-bar', selector: 'app-title-bar',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon], imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })], viewProviders: [
provideIcons({ lucideMinus,
lucideSquare,
lucideX,
lucideChevronLeft,
lucideHash,
lucideMenu })
],
templateUrl: './title-bar.component.html' templateUrl: './title-bar.component.html'
}) })
/** /**
@@ -102,7 +121,7 @@ export class TitleBarComponent {
/** Log out the current user, disconnect from signaling, and navigate to login. */ /** Log out the current user, disconnect from signaling, and navigate to login. */
logout() { logout() {
this._showMenu.set(false); this._showMenu.set(false);
// Disconnect from signaling server this broadcasts "user_left" to all // Disconnect from signaling server - this broadcasts "user_left" to all
// servers the user was a member of, so other users see them go offline. // servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect(); this.webrtc.disconnect();

View File

@@ -9,7 +9,10 @@
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors" class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
title="Back to {{ voiceSession()?.serverName }}" title="Back to {{ voiceSession()?.serverName }}"
> >
<ng-icon name="lucideArrowLeft" class="w-3.5 h-3.5" /> <ng-icon
name="lucideArrowLeft"
class="w-3.5 h-3.5"
/>
@if (voiceSession()?.serverIcon) { @if (voiceSession()?.serverIcon) {
<img <img
[src]="voiceSession()?.serverIcon" [src]="voiceSession()?.serverIcon"
@@ -40,7 +43,10 @@
[class]="getCompactButtonClass(isMuted())" [class]="getCompactButtonClass(isMuted())"
title="Toggle Mute" title="Toggle Mute"
> >
<ng-icon [name]="isMuted() ? 'lucideMicOff' : 'lucideMic'" class="w-4 h-4" /> <ng-icon
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
class="w-4 h-4"
/>
</button> </button>
<button <button
@@ -49,7 +55,10 @@
[class]="getCompactButtonClass(isDeafened())" [class]="getCompactButtonClass(isDeafened())"
title="Toggle Deafen" title="Toggle Deafen"
> >
<ng-icon name="lucideHeadphones" class="w-4 h-4" /> <ng-icon
name="lucideHeadphones"
class="w-4 h-4"
/>
</button> </button>
<button <button
@@ -58,7 +67,10 @@
[class]="getCompactScreenShareClass()" [class]="getCompactScreenShareClass()"
title="Toggle Screen Share" title="Toggle Screen Share"
> >
<ng-icon [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" class="w-4 h-4" /> <ng-icon
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="w-4 h-4"
/>
</button> </button>
<button <button
@@ -67,7 +79,10 @@
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors" class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
title="Disconnect" title="Disconnect"
> >
<ng-icon name="lucidePhoneOff" class="w-4 h-4" /> <ng-icon
name="lucidePhoneOff"
class="w-4 h-4"
/>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,12 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core'; import {
Component,
inject,
signal,
computed,
OnInit,
OnDestroy
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -163,7 +170,11 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
if (user?.id) { if (user?.id) {
this.store.dispatch(UsersActions.updateVoiceState({ this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id, userId: user.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } voiceState: { isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined }
})); }));
} }

View File

@@ -18,11 +18,12 @@
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity"> <div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-white"> <div class="flex items-center gap-2 text-white">
<ng-icon name="lucideMonitor" class="w-4 h-4" /> <ng-icon
name="lucideMonitor"
class="w-4 h-4"
/>
@if (activeScreenSharer()) { @if (activeScreenSharer()) {
<span class="text-sm font-medium"> <span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
{{ activeScreenSharer()?.displayName }} is sharing their screen
</span>
} @else { } @else {
<span class="text-sm font-medium">Someone is sharing their screen</span> <span class="text-sm font-medium">Someone is sharing their screen</span>
} }
@@ -46,9 +47,15 @@
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors" class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
> >
@if (isFullscreen()) { @if (isFullscreen()) {
<ng-icon name="lucideMinimize" class="w-4 h-4 text-white" /> <ng-icon
name="lucideMinimize"
class="w-4 h-4 text-white"
/>
} @else { } @else {
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" /> <ng-icon
name="lucideMaximize"
class="w-4 h-4 text-white"
/>
} }
</button> </button>
@if (isLocalShare()) { @if (isLocalShare()) {
@@ -58,7 +65,10 @@
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors" class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop sharing" title="Stop sharing"
> >
<ng-icon name="lucideX" class="w-4 h-4 text-white" /> <ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button> </button>
} @else { } @else {
<button <button
@@ -67,7 +77,10 @@
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors" class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop watching" title="Stop watching"
> >
<ng-icon name="lucideX" class="w-4 h-4 text-white" /> <ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button> </button>
} }
</div> </div>
@@ -78,7 +91,10 @@
@if (!hasStream()) { @if (!hasStream()) {
<div class="absolute inset-0 flex items-center justify-center bg-secondary"> <div class="absolute inset-0 flex items-center justify-center bg-secondary">
<div class="text-center text-muted-foreground"> <div class="text-center text-muted-foreground">
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" /> <ng-icon
name="lucideMonitor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
/>
<p>Waiting for screen share...</p> <p>Waiting for screen share...</p>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core'; import {
Component,
inject,
signal,
ElementRef,
ViewChild,
OnDestroy,
effect
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -235,6 +243,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
.catch(() => {}); .catch(() => {});
} catch {} } catch {}
}); });
this.hasStream.set(true); this.hasStream.set(true);
} }
} }

View File

@@ -146,6 +146,7 @@ export class VoicePlaybackService {
audio.srcObject = null; audio.srcObject = null;
audio.remove(); audio.remove();
}); });
this.remoteAudioElements.clear(); this.remoteAudioElements.clear();
this.rawRemoteStreams.clear(); this.rawRemoteStreams.clear();
this.pendingRemoteStreams.clear(); this.pendingRemoteStreams.clear();

View File

@@ -1,14 +1,14 @@
<div class="bg-card border-border p-4"> <div class="bg-card border-border p-4">
<!-- Connection Error Banner --> <!-- Connection Error Banner -->
@if (showConnectionError()) { @if (showConnectionError()) {
<div <div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2"
>
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
<span class="text-xs text-destructive">{{ <span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
connectionErrorMessage() || 'Connection error' <button
}}</span> type="button"
<button type="button" (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline"> (click)="retryConnection()"
class="ml-auto text-xs text-destructive hover:underline"
>
Retry Retry
</button> </button>
</div> </div>
@@ -16,7 +16,10 @@
<!-- User Info --> <!-- User Info -->
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" /> <app-user-avatar
[name]="currentUser()?.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium text-sm text-foreground truncate"> <p class="font-medium text-sm text-foreground truncate">
{{ currentUser()?.displayName || 'Unknown' }} {{ currentUser()?.displayName || 'Unknown' }}
@@ -31,8 +34,15 @@
} }
</p> </p>
</div> </div>
<button type="button" (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors"> <button
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" /> type="button"
(click)="toggleSettings()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
>
<ng-icon
name="lucideSettings"
class="w-4 h-4 text-muted-foreground"
/>
</button> </button>
</div> </div>
@@ -40,25 +50,52 @@
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
@if (isConnected()) { @if (isConnected()) {
<!-- Mute Toggle --> <!-- Mute Toggle -->
<button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()"> <button
type="button"
(click)="toggleMute()"
[class]="getMuteButtonClass()"
>
@if (isMuted()) { @if (isMuted()) {
<ng-icon name="lucideMicOff" class="w-5 h-5" /> <ng-icon
name="lucideMicOff"
class="w-5 h-5"
/>
} @else { } @else {
<ng-icon name="lucideMic" class="w-5 h-5" /> <ng-icon
name="lucideMic"
class="w-5 h-5"
/>
} }
</button> </button>
<!-- Deafen Toggle --> <!-- Deafen Toggle -->
<button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()"> <button
<ng-icon name="lucideHeadphones" class="w-5 h-5" /> type="button"
(click)="toggleDeafen()"
[class]="getDeafenButtonClass()"
>
<ng-icon
name="lucideHeadphones"
class="w-5 h-5"
/>
</button> </button>
<!-- Screen Share Toggle --> <!-- Screen Share Toggle -->
<button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()"> <button
type="button"
(click)="toggleScreenShare()"
[class]="getScreenShareButtonClass()"
>
@if (isScreenSharing()) { @if (isScreenSharing()) {
<ng-icon name="lucideMonitorOff" class="w-5 h-5" /> <ng-icon
name="lucideMonitorOff"
class="w-5 h-5"
/>
} @else { } @else {
<ng-icon name="lucideMonitor" class="w-5 h-5" /> <ng-icon
name="lucideMonitor"
class="w-5 h-5"
/>
} }
</button> </button>
@@ -68,7 +105,10 @@
(click)="disconnect()" (click)="disconnect()"
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors" class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
> >
<ng-icon name="lucidePhoneOff" class="w-5 h-5" /> <ng-icon
name="lucidePhoneOff"
class="w-5 h-5"
/>
</button> </button>
} }
</div> </div>

View File

@@ -43,7 +43,11 @@ interface AudioDevice {
@Component({ @Component({
selector: 'app-voice-controls', selector: 'app-voice-controls',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon, UserAvatarComponent], imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideMic, lucideMic,
@@ -164,12 +168,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.inputDevices.set( this.inputDevices.set(
devices devices
.filter((device) => device.kind === 'audioinput') .filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId, label: device.label })) .map((device) => ({ deviceId: device.deviceId,
label: device.label }))
); );
this.outputDevices.set( this.outputDevices.set(
devices devices
.filter((device) => device.kind === 'audiooutput') .filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId, label: device.label })) .map((device) => ({ deviceId: device.deviceId,
label: device.label }))
); );
} catch (_error) {} } catch (_error) {}
} }
@@ -502,7 +509,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.setLatencyProfile(this.latencyProfile()); this.webrtcService.setLatencyProfile(this.latencyProfile());
this.applyOutputDevice(); this.applyOutputDevice();
// Always sync the desired noise-reduction preference (even before // Always sync the desired noise-reduction preference (even before
// a mic stream exists the flag will be honoured on connect). // a mic stream exists - the flag will be honoured on connect).
this.webrtcService.toggleNoiseReduction(this.noiseReduction()); this.webrtcService.toggleNoiseReduction(this.noiseReduction());
} catch {} } catch {}
} }

View File

@@ -1,4 +1,9 @@
import { Component, input, output, HostListener } from '@angular/core'; import {
Component,
input,
output,
HostListener
} from '@angular/core';
/** /**
* Reusable confirmation dialog modal. * Reusable confirmation dialog modal.

View File

@@ -1,4 +1,9 @@
import { Component, input, output, HostListener } from '@angular/core'; import {
Component,
input,
output,
HostListener
} from '@angular/core';
/** /**
* Generic positioned context-menu overlay. * Generic positioned context-menu overlay.
@@ -13,9 +18,9 @@ import { Component, input, output, HostListener } from '@angular/core';
* ``` * ```
* *
* Built-in item classes are available via the host styles: * Built-in item classes are available via the host styles:
* - `.context-menu-item` normal item * - `.context-menu-item` - normal item
* - `.context-menu-item-danger` destructive (red) item * - `.context-menu-item-danger` - destructive (red) item
* - `.context-menu-divider` horizontal separator * - `.context-menu-divider` - horizontal separator
*/ */
@Component({ @Component({
selector: 'app-context-menu', selector: 'app-context-menu',

View File

@@ -37,7 +37,7 @@ import { Component, input } from '@angular/core';
styles: [':host { display: contents; }'] styles: [':host { display: contents; }']
}) })
export class UserAvatarComponent { export class UserAvatarComponent {
/** Display name first character is used as fallback initial. */ /** Display name - first character is used as fallback initial. */
name = input.required<string>(); name = input.required<string>();
/** Optional avatar image URL. */ /** Optional avatar image URL. */
avatarUrl = input<string | undefined | null>(); avatarUrl = input<string | undefined | null>();

View File

@@ -2,9 +2,9 @@
* Root state definition and barrel exports for the NgRx store. * Root state definition and barrel exports for the NgRx store.
* *
* Three feature slices: * Three feature slices:
* - **messages** chat messages, reactions, sync state * - **messages** - chat messages, reactions, sync state
* - **users** online users, bans, roles, voice state * - **users** - online users, bans, roles, voice state
* - **rooms** servers / rooms, channels, search results * - **rooms** - servers / rooms, channels, search results
*/ */
import { isDevMode } from '@angular/core'; import { isDevMode } from '@angular/core';
import { ActionReducerMap, MetaReducer } from '@ngrx/store'; import { ActionReducerMap, MetaReducer } from '@ngrx/store';

View File

@@ -9,7 +9,12 @@
* handlers, and `dispatchIncomingMessage()` is the single entry point * handlers, and `dispatchIncomingMessage()` is the single entry point
* consumed by the `incomingMessages$` effect. * consumed by the `incomingMessages$` effect.
*/ */
import { Observable, of, from, EMPTY } from 'rxjs'; import {
Observable,
of,
from,
EMPTY
} from 'rxjs';
import { mergeMap } from 'rxjs/operators'; import { mergeMap } from 'rxjs/operators';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Message } from '../../core/models'; import { Message } from '../../core/models';
@@ -264,6 +269,7 @@ function handleMessageEdited(
content: event.content, content: event.content,
editedAt: event.editedAt editedAt: event.editedAt
}); });
return of( return of(
MessagesActions.editMessageSuccess({ MessagesActions.editMessageSuccess({
messageId: event.messageId, messageId: event.messageId,

View File

@@ -10,9 +10,19 @@
*/ */
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { of, from, timer, Subject, EMPTY } from 'rxjs'; import {
of,
from,
timer,
Subject,
EMPTY
} from 'rxjs';
import { import {
map, map,
mergeMap, mergeMap,
@@ -78,6 +88,7 @@ export class MessagesSyncEffects {
count, count,
lastUpdated lastUpdated
} as any); } as any);
this.webrtc.sendToPeer(peerId, { this.webrtc.sendToPeer(peerId, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: room.id roomId: room.id
@@ -119,6 +130,7 @@ export class MessagesSyncEffects {
count, count,
lastUpdated lastUpdated
} as any); } as any);
this.webrtc.sendToPeer(pid, { this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request', type: 'chat-inventory-request',
roomId: activeRoom.id roomId: activeRoom.id

View File

@@ -4,7 +4,11 @@
* Action type strings follow the `[Messages] Event Name` convention and are * Action type strings follow the `[Messages] Event Name` convention and are
* generated automatically by NgRx from the `source` and event key. * generated automatically by NgRx from the `source` and event key.
*/ */
import { createActionGroup, emptyProps, props } from '@ngrx/store'; import {
createActionGroup,
emptyProps,
props
} from '@ngrx/store';
import { Message, Reaction } from '../../core/models'; import { Message, Reaction } from '../../core/models';
export const MessagesActions = createActionGroup({ export const MessagesActions = createActionGroup({

View File

@@ -10,10 +10,23 @@
*/ */
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { of, from, EMPTY } from 'rxjs'; import {
import { mergeMap, catchError, withLatestFrom, switchMap } from 'rxjs/operators'; of,
from,
EMPTY
} from 'rxjs';
import {
mergeMap,
catchError,
withLatestFrom,
switchMap
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentUser } from '../users/users.selectors';
@@ -24,10 +37,7 @@ import { TimeSyncService } from '../../core/services/time-sync.service';
import { AttachmentService } from '../../core/services/attachment.service'; import { AttachmentService } from '../../core/services/attachment.service';
import { Message, Reaction } from '../../core/models'; import { Message, Reaction } from '../../core/models';
import { hydrateMessages } from './messages.helpers'; import { hydrateMessages } from './messages.helpers';
import { import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
dispatchIncomingMessage,
IncomingMessageContext
} from './messages-incoming.handlers';
@Injectable() @Injectable()
export class MessagesEffects { export class MessagesEffects {
@@ -65,7 +75,11 @@ export class MessagesEffects {
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom)
), ),
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => { mergeMap(([
{ content, replyToId, channelId },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom) { if (!currentUser || !currentRoom) {
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' })); return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
} }
@@ -84,7 +98,8 @@ export class MessagesEffects {
}; };
this.db.saveMessage(message); this.db.saveMessage(message);
this.webrtc.broadcastMessage({ type: 'chat-message', message }); this.webrtc.broadcastMessage({ type: 'chat-message',
message });
return of(MessagesActions.sendMessageSuccess({ message })); return of(MessagesActions.sendMessageSuccess({ message }));
}), }),
@@ -116,10 +131,17 @@ export class MessagesEffects {
const editedAt = this.timeSync.now(); const editedAt = this.timeSync.now();
this.db.updateMessage(messageId, { content, editedAt }); this.db.updateMessage(messageId, { content,
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt }); editedAt });
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt })); this.webrtc.broadcastMessage({ type: 'message-edited',
messageId,
content,
editedAt });
return of(MessagesActions.editMessageSuccess({ messageId,
content,
editedAt }));
}), }),
catchError((error) => catchError((error) =>
of(MessagesActions.editMessageFailure({ error: error.message })) of(MessagesActions.editMessageFailure({ error: error.message }))
@@ -150,7 +172,8 @@ export class MessagesEffects {
} }
this.db.updateMessage(messageId, { isDeleted: true }); this.db.updateMessage(messageId, { isDeleted: true });
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId }); this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId });
return of(MessagesActions.deleteMessageSuccess({ messageId })); return of(MessagesActions.deleteMessageSuccess({ messageId }));
}), }),
@@ -182,7 +205,9 @@ export class MessagesEffects {
} }
this.db.updateMessage(messageId, { isDeleted: true }); this.db.updateMessage(messageId, { isDeleted: true });
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedBy: currentUser.id }); this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId,
deletedBy: currentUser.id });
return of(MessagesActions.deleteMessageSuccess({ messageId })); return of(MessagesActions.deleteMessageSuccess({ messageId }));
}), }),
@@ -211,7 +236,9 @@ export class MessagesEffects {
}; };
this.db.saveReaction(reaction); this.db.saveReaction(reaction);
this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction }); this.webrtc.broadcastMessage({ type: 'reaction-added',
messageId,
reaction });
return of(MessagesActions.addReactionSuccess({ reaction })); return of(MessagesActions.addReactionSuccess({ reaction }));
}) })
@@ -256,7 +283,11 @@ export class MessagesEffects {
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom)
), ),
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => { mergeMap(([
event,
currentUser,
currentRoom]: [any, any, any
]) => {
const ctx: IncomingMessageContext = { const ctx: IncomingMessageContext = {
db: this.db, db: this.db,
webrtc: this.webrtc, webrtc: this.webrtc,

View File

@@ -56,7 +56,8 @@ export async function hydrateMessage(
): Promise<Message> { ): Promise<Message> {
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
return reactions.length > 0 ? { ...msg, reactions } : msg; return reactions.length > 0 ? { ...msg,
reactions } : msg;
} }
/** Hydrates an array of messages with their reactions. */ /** Hydrates an array of messages with their reactions. */
@@ -81,7 +82,9 @@ export async function buildInventoryItem(
): Promise<InventoryItem> { ): Promise<InventoryItem> {
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
return { id: msg.id, ts: getMessageTimestamp(msg), rc: reactions.length }; return { id: msg.id,
ts: getMessageTimestamp(msg),
rc: reactions.length };
} }
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */ /** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
@@ -95,9 +98,11 @@ export async function buildLocalInventoryMap(
messages.map(async (msg) => { messages.map(async (msg) => {
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
map.set(msg.id, { ts: getMessageTimestamp(msg), rc: reactions.length }); map.set(msg.id, { ts: getMessageTimestamp(msg),
rc: reactions.length });
}) })
); );
return map; return map;
} }
@@ -161,18 +166,22 @@ export async function mergeIncomingMessage(
const baseMessage = isNewer ? incoming : existing; const baseMessage = isNewer ? incoming : existing;
if (!baseMessage) { if (!baseMessage) {
return { message: incoming, changed }; return { message: incoming,
changed };
} }
return { return {
message: { ...baseMessage, reactions }, message: { ...baseMessage,
reactions },
changed changed
}; };
} }
if (!existing) { if (!existing) {
return { message: incoming, changed: false }; return { message: incoming,
changed: false };
} }
return { message: existing, changed: false }; return { message: existing,
changed: false };
} }

View File

@@ -1,5 +1,9 @@
import { createReducer, on } from '@ngrx/store'; import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; import {
EntityState,
EntityAdapter,
createEntityAdapter
} from '@ngrx/entity';
import { Message } from '../../core/models'; import { Message } from '../../core/models';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
@@ -30,7 +34,7 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
export const messagesReducer = createReducer( export const messagesReducer = createReducer(
initialState, initialState,
// Load messages clear stale messages when switching to a different room // Load messages - clear stale messages when switching to a different room
on(MessagesActions.loadMessages, (state, { roomId }) => { on(MessagesActions.loadMessages, (state, { roomId }) => {
if (state.currentRoomId && state.currentRoomId !== roomId) { if (state.currentRoomId && state.currentRoomId !== roomId) {
return messagesAdapter.removeAll({ return messagesAdapter.removeAll({
@@ -91,7 +95,8 @@ export const messagesReducer = createReducer(
messagesAdapter.updateOne( messagesAdapter.updateOne(
{ {
id: messageId, id: messageId,
changes: { content, editedAt } changes: { content,
editedAt }
}, },
state state
) )
@@ -102,7 +107,8 @@ export const messagesReducer = createReducer(
messagesAdapter.updateOne( messagesAdapter.updateOne(
{ {
id: messageId, id: messageId,
changes: { isDeleted: true, content: '[Message deleted]' } changes: { isDeleted: true,
content: '[Message deleted]' }
}, },
state state
) )
@@ -184,7 +190,8 @@ export const messagesReducer = createReducer(
} }
} }
return { ...message, reactions: combined }; return { ...message,
reactions: combined };
} }
return message; return message;

View File

@@ -1,8 +1,18 @@
/** /**
* Rooms store actions using `createActionGroup`. * Rooms store actions using `createActionGroup`.
*/ */
import { createActionGroup, emptyProps, props } from '@ngrx/store'; import {
import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models'; createActionGroup,
emptyProps,
props
} from '@ngrx/store';
import {
Room,
RoomSettings,
ServerInfo,
RoomPermissions,
Channel
} from '../../core/models';
export const RoomsActions = createActionGroup({ export const RoomsActions = createActionGroup({
source: 'Rooms', source: 'Rooms',

View File

@@ -3,9 +3,17 @@
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { of, from, EMPTY } from 'rxjs'; import {
of,
from,
EMPTY
} from 'rxjs';
import { import {
map, map,
mergeMap, mergeMap,
@@ -25,7 +33,12 @@ import { selectCurrentRoom } from './rooms.selectors';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service'; import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { Room, RoomSettings, RoomPermissions, VoiceState } from '../../core/models'; import {
Room,
RoomSettings,
RoomPermissions,
VoiceState
} from '../../core/models';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
/** Build a minimal User object from signaling payload. */ /** Build a minimal User object from signaling payload. */
@@ -285,11 +298,16 @@ export class RoomsEffects {
ofType(RoomsActions.deleteRoom), ofType(RoomsActions.deleteRoom),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
filter( filter(
([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id ([
, currentUser,
currentRoom
]) => !!currentUser && currentRoom?.hostId === currentUser.id
), ),
switchMap(([{ roomId }]) => { switchMap(([{ roomId }]) => {
this.db.deleteRoom(roomId); this.db.deleteRoom(roomId);
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId }); this.webrtc.broadcastMessage({ type: 'room-deleted',
roomId });
this.webrtc.disconnectAll(); this.webrtc.disconnectAll();
return of(RoomsActions.deleteRoomSuccess({ roomId })); return of(RoomsActions.deleteRoomSuccess({ roomId }));
}) })
@@ -318,7 +336,11 @@ export class RoomsEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.updateRoomSettings), ofType(RoomsActions.updateRoomSettings),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
mergeMap(([{ settings }, currentUser, currentRoom]) => { mergeMap(([
{ settings },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom) { if (!currentUser || !currentRoom) {
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' })); return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' }));
} }
@@ -377,15 +399,22 @@ export class RoomsEffects {
ofType(RoomsActions.updateRoomPermissions), ofType(RoomsActions.updateRoomPermissions),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
filter( filter(
([{ roomId }, currentUser, currentRoom]) => ([
{ roomId },
currentUser,
currentRoom
]) =>
!!currentUser && !!currentUser &&
!!currentRoom && !!currentRoom &&
currentRoom.id === roomId && currentRoom.id === roomId &&
currentRoom.hostId === currentUser.id currentRoom.hostId === currentUser.id
), ),
mergeMap(([{ roomId, permissions }, , currentRoom]) => { mergeMap(([
{ roomId, permissions }, , currentRoom
]) => {
const updated: Partial<Room> = { const updated: Partial<Room> = {
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions permissions: { ...(currentRoom!.permissions || {}),
...permissions } as RoomPermissions
}; };
this.db.updateRoom(roomId, updated); this.db.updateRoom(roomId, updated);
@@ -394,7 +423,9 @@ export class RoomsEffects {
type: 'room-permissions-update', type: 'room-permissions-update',
permissions: updated.permissions permissions: updated.permissions
} as any); } as any);
return of(RoomsActions.updateRoom({ roomId, changes: updated }));
return of(RoomsActions.updateRoom({ roomId,
changes: updated }));
}) })
) )
); );
@@ -404,7 +435,11 @@ export class RoomsEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.updateServerIcon), ofType(RoomsActions.updateServerIcon),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
mergeMap(([{ roomId, icon }, currentUser, currentRoom]) => { mergeMap(([
{ roomId, icon },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom || currentRoom.id !== roomId) { if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' })); return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
} }
@@ -421,7 +456,8 @@ export class RoomsEffects {
} }
const iconUpdatedAt = Date.now(); const iconUpdatedAt = Date.now();
const changes: Partial<Room> = { icon, iconUpdatedAt }; const changes: Partial<Room> = { icon,
iconUpdatedAt };
this.db.updateRoom(roomId, changes); this.db.updateRoom(roomId, changes);
// Broadcast to peers // Broadcast to peers
@@ -431,7 +467,10 @@ export class RoomsEffects {
icon, icon,
iconUpdatedAt iconUpdatedAt
} as any); } as any);
return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt }));
return of(RoomsActions.updateServerIconSuccess({ roomId,
icon,
iconUpdatedAt }));
}) })
) )
); );
@@ -491,7 +530,11 @@ export class RoomsEffects {
signalingMessages$ = createEffect(() => signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe( this.webrtc.onSignalingMessage.pipe(
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => { mergeMap(([
message,
currentUser,
currentRoom]: [any, any, any
]) => {
const myId = currentUser?.oderId || currentUser?.id; const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id; const viewedServerId = currentRoom?.id;
@@ -533,7 +576,11 @@ export class RoomsEffects {
this.webrtc.onMessageReceived.pipe( this.webrtc.onMessageReceived.pipe(
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectAllUsers)), withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectAllUsers)),
filter(([, room]) => !!room), filter(([, room]) => !!room),
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => { mergeMap(([
event,
currentRoom,
allUsers]: [any, any, any[]
]) => {
const room = currentRoom as Room; const room = currentRoom as Room;
switch (event.type) { switch (event.type) {
@@ -590,7 +637,8 @@ export class RoomsEffects {
return of( return of(
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser( user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || 'User' }, { oderId: userId,
displayName: event.displayName || 'User' },
{ {
voiceState: { voiceState: {
isConnected: vs.isConnected ?? false, isConnected: vs.isConnected ?? false,
@@ -608,7 +656,8 @@ export class RoomsEffects {
); );
} }
return of(UsersActions.updateVoiceState({ userId, voiceState: vs })); return of(UsersActions.updateVoiceState({ userId,
voiceState: vs }));
} }
// screen-state // screen-state
@@ -621,7 +670,8 @@ export class RoomsEffects {
return of( return of(
UsersActions.userJoined({ UsersActions.userJoined({
user: buildSignalingUser( user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || 'User' }, { oderId: userId,
displayName: event.displayName || 'User' },
{ screenShareState: { isSharing } } { screenShareState: { isSharing } }
) )
}) })
@@ -700,7 +750,8 @@ export class RoomsEffects {
}; };
this.db.updateRoom(room.id, updates); this.db.updateRoom(room.id, updates);
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates })); return of(RoomsActions.updateRoom({ roomId: room.id,
changes: updates }));
}) })
); );
} }

View File

@@ -1,14 +1,31 @@
import { createReducer, on } from '@ngrx/store'; import { createReducer, on } from '@ngrx/store';
import { Room, ServerInfo, RoomSettings, Channel } from '../../core/models'; import {
Room,
ServerInfo,
RoomSettings,
Channel
} from '../../core/models';
import { RoomsActions } from './rooms.actions'; import { RoomsActions } from './rooms.actions';
/** Default channels for a new server */ /** Default channels for a new server */
export function defaultChannels(): Channel[] { export function defaultChannels(): Channel[] {
return [ return [
{ id: 'general', name: 'general', type: 'text', position: 0 }, { id: 'general',
{ id: 'random', name: 'random', type: 'text', position: 1 }, name: 'general',
{ id: 'vc-general', name: 'General', type: 'voice', position: 0 }, type: 'text',
{ id: 'vc-afk', name: 'AFK', type: 'voice', position: 1 } position: 0 },
{ id: 'random',
name: 'random',
type: 'text',
position: 1 },
{ id: 'vc-general',
name: 'General',
type: 'voice',
position: 0 },
{ id: 'vc-afk',
name: 'AFK',
type: 'voice',
position: 1 }
]; ];
} }
@@ -123,7 +140,8 @@ export const roomsReducer = createReducer(
})), })),
on(RoomsActions.createRoomSuccess, (state, { room }) => { on(RoomsActions.createRoomSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() }; const enriched = { ...room,
channels: room.channels || defaultChannels() };
return { return {
...state, ...state,
@@ -149,7 +167,8 @@ export const roomsReducer = createReducer(
})), })),
on(RoomsActions.joinRoomSuccess, (state, { room }) => { on(RoomsActions.joinRoomSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() }; const enriched = { ...room,
channels: room.channels || defaultChannels() };
return { return {
...state, ...state,
@@ -181,7 +200,7 @@ export const roomsReducer = createReducer(
isConnected: false isConnected: false
})), })),
// View server just switch the viewed room, stay connected // View server - just switch the viewed room, stay connected
on(RoomsActions.viewServer, (state) => ({ on(RoomsActions.viewServer, (state) => ({
...state, ...state,
isConnecting: true, isConnecting: true,
@@ -189,7 +208,8 @@ export const roomsReducer = createReducer(
})), })),
on(RoomsActions.viewServerSuccess, (state, { room }) => { on(RoomsActions.viewServerSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() }; const enriched = { ...room,
channels: room.channels || defaultChannels() };
return { return {
...state, ...state,
@@ -264,7 +284,8 @@ export const roomsReducer = createReducer(
return { return {
...state, ...state,
currentRoom: { ...state.currentRoom, ...changes } currentRoom: { ...state.currentRoom,
...changes }
}; };
}), }),
@@ -275,14 +296,17 @@ export const roomsReducer = createReducer(
return { return {
...state, ...state,
currentRoom: { ...state.currentRoom, icon, iconUpdatedAt } currentRoom: { ...state.currentRoom,
icon,
iconUpdatedAt }
}; };
}), }),
// Receive room update // Receive room update
on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({ on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({
...state, ...state,
currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null currentRoom: state.currentRoom ? { ...state.currentRoom,
...room } : null
})), })),
// Clear search results // Clear search results
@@ -309,7 +333,8 @@ export const roomsReducer = createReducer(
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = [...existing, channel]; const updatedChannels = [...existing, channel];
const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return { return {
...state, ...state,
@@ -324,7 +349,8 @@ export const roomsReducer = createReducer(
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.filter(channel => channel.id !== channelId); const updatedChannels = existing.filter(channel => channel.id !== channelId);
const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return { return {
...state, ...state,
@@ -339,8 +365,10 @@ export const roomsReducer = createReducer(
return state; return state;
const existing = state.currentRoom.channels || defaultChannels(); const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel); const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; name } : channel);
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return { return {
...state, ...state,

View File

@@ -1,8 +1,17 @@
/** /**
* Users store actions using `createActionGroup`. * Users store actions using `createActionGroup`.
*/ */
import { createActionGroup, emptyProps, props } from '@ngrx/store'; import {
import { User, BanEntry, VoiceState, ScreenShareState } from '../../core/models'; createActionGroup,
emptyProps,
props
} from '@ngrx/store';
import {
User,
BanEntry,
VoiceState,
ScreenShareState
} from '../../core/models';
export const UsersActions = createActionGroup({ export const UsersActions = createActionGroup({
source: 'Users', source: 'Users',

View File

@@ -3,13 +3,32 @@
*/ */
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { of, from, EMPTY } from 'rxjs'; import {
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators'; of,
from,
EMPTY
} from 'rxjs';
import {
map,
mergeMap,
catchError,
withLatestFrom,
tap,
switchMap
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { UsersActions } from './users.actions'; import { UsersActions } from './users.actions';
import { selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors'; import {
selectCurrentUser,
selectCurrentUserId,
selectHostId
} from './users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
@@ -67,7 +86,11 @@ export class UsersEffects {
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom)
), ),
mergeMap(([{ userId }, currentUser, currentRoom]) => { mergeMap(([
{ userId },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom) if (!currentUser || !currentRoom)
return EMPTY; return EMPTY;
@@ -99,7 +122,11 @@ export class UsersEffects {
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom)
), ),
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => { mergeMap(([
{ userId, reason, expiresAt },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom) if (!currentUser || !currentRoom)
return EMPTY; return EMPTY;
@@ -127,7 +154,8 @@ export class UsersEffects {
reason reason
}); });
return of(UsersActions.banUserSuccess({ userId, ban })); return of(UsersActions.banUserSuccess({ userId,
ban }));
}) })
) )
); );
@@ -171,7 +199,11 @@ export class UsersEffects {
this.store.select(selectHostId), this.store.select(selectHostId),
this.store.select(selectCurrentUserId) this.store.select(selectCurrentUserId)
), ),
mergeMap(([{ userId }, hostId, currentUserId]) => mergeMap(([
{ userId },
hostId,
currentUserId
]) =>
userId === hostId && currentUserId userId === hostId && currentUserId
? of(UsersActions.updateHost({ userId: currentUserId })) ? of(UsersActions.updateHost({ userId: currentUserId }))
: EMPTY : EMPTY

View File

@@ -1,5 +1,9 @@
import { createReducer, on } from '@ngrx/store'; import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; import {
EntityState,
EntityAdapter,
createEntityAdapter
} from '@ngrx/entity';
import { User, BanEntry } from '../../core/models'; import { User, BanEntry } from '../../core/models';
import { UsersActions } from './users.actions'; import { UsersActions } from './users.actions';

24
tools/eslint-rules.js Normal file
View File

@@ -0,0 +1,24 @@
// Custom ESLint rules for Angular template formatting
// This enforces the specific formatting style for Angular templates
module.exports = {
rules: {
'angular-template-spacing': {
meta: {
type: 'layout',
docs: {
description: 'Enforce spacing between elements and property grouping in Angular templates',
category: 'Stylistic Issues',
recommended: true,
},
fixable: 'whitespace',
schema: [],
},
create(context) {
// This is a placeholder for custom rule implementation
// ESLint's template rules are limited, so manual formatting is recommended
return {};
},
},
},
};

138
tools/format-templates.js Normal file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* Custom Angular Template Formatter
* Enforces specific formatting rules for Angular HTML templates:
* 1. Blank lines between sibling elements (except first)
* 2. Property grouping: outputs → two-way → inputs → attributes
* 3. Multi-line properties when 3+
* 4. Proper indentation in @if/@for/@switch blocks
*/
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const PROPERTY_ORDER = {
'STRUCTURAL_DIRECTIVE': 0,
'TEMPLATE_REFERENCE': 1,
'OUTPUT_BINDING': 2,
'TWO_WAY_BINDING': 3,
'INPUT_BINDING': 4,
'ATTRIBUTE_BINDING': 5,
};
// Detect property type
function getPropertyType(attr) {
if (attr.match(/^\(\w+\)/)) return 'OUTPUT_BINDING'; // (click)
if (attr.match(/^\[\(\w+\)\]/)) return 'TWO_WAY_BINDING'; // [(ngModel)]
if (attr.match(/^\[[\w\-.]+\]/)) return 'INPUT_BINDING'; // [property]
if (attr.match(/^#\w+/)) return 'TEMPLATE_REFERENCE'; // #ref
if (attr.match(/^@/)) return 'STRUCTURAL_DIRECTIVE'; // @if
return 'ATTRIBUTE_BINDING'; // class, id, etc.
}
// Sort attributes by property type
function sortAttributes(attrs) {
return attrs.sort((a, b) => {
const typeA = getPropertyType(a);
const typeB = getPropertyType(b);
return (PROPERTY_ORDER[typeA] || 99) - (PROPERTY_ORDER[typeB] || 99);
});
}
// Format an element's attributes
function formatAttributes(element) {
const attrRegex = /(\s+)([\w\-\.\:\@\[\(\#\*]+(?:[\w\-\.\:\@\[\(\#\*\)"'=\s\[\]]*)?)/g;
// Extract all attributes
const matches = element.match(/\s+[^\s>]+(?:="[^"]*")?/g) || [];
if (!matches.length) return element;
const attrs = matches
.map(m => m.trim())
.filter(m => m.length > 0);
// Sort attributes
const sorted = sortAttributes(attrs);
// Format based on count
if (sorted.length <= 2) {
// Keep on same line if 2 or fewer
return element.replace(/\s+[\w\-\.\:\@\[\(\#\*][^\s>]*(?:="[^"]*")?/g, '').replace(/>/, ' ' + sorted.join(' ') + '>');
} else {
// Put each on new line if 3+
const indent = ' '; // Assuming 4-space indent
const tag = element.match(/^<\w+/)[0];
return tag + '\n' + sorted.map(a => indent + a).join('\n') + '\n>';
}
}
// Add blank lines between siblings
function addBlankLinesBetweenSiblings(content) {
// Match closing and opening tags on consecutive lines
const pattern = /(<\/\w+>)\n(?!$)(<\w+|\s*<\w+|\s*@)/gm;
return content.replace(pattern, '$1\n\n$2');
}
// Ensure @if/@for/@switch blocks are properly indented
function formatControlFlowBlocks(content) {
const controlFlowRegex = /@(if|for|switch|case|default)\s*\([^)]*\)\s*\{/g;
let result = content;
result = result.replace(controlFlowRegex, (match) => {
// Ensure content after { is on new line and indented
return match.replace(/\}\s*$/m, '}\n');
});
return result;
}
// Main formatter function
function formatAngularTemplate(content) {
// First, add blank lines between siblings
content = addBlankLinesBetweenSiblings(content);
// Ensure control flow blocks are properly formatted
content = formatControlFlowBlocks(content);
return content;
}
// Process a file
function processFile(filePath) {
try {
let content = fs.readFileSync(filePath, 'utf-8');
const original = content;
content = formatAngularTemplate(content);
if (content !== original) {
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`✓ Formatted: ${filePath}`);
return true;
}
} catch (error) {
console.error(`✗ Error processing ${filePath}:`, error.message);
}
return false;
}
// Main
const args = process.argv.slice(2);
const files = args.length > 0
? args
: glob.sync('src/app/**/*.html', { cwd: process.cwd() });
let formatted = 0;
files.forEach(file => {
const fullPath = path.join(process.cwd(), file);
if (fs.existsSync(fullPath)) {
if (processFile(fullPath)) {
formatted++;
}
}
});
console.log(`\nTotal files formatted: ${formatted}`);
process.exit(formatted > 0 ? 0 : 0);

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Prettier Plugin Wrapper for Property Sorting
* This file hooks into the formatting pipeline to ensure property sorting
* happens automatically whenever Prettier runs.
*
* Usage: Configure in .prettierrc.json to use this as a post-processor
*/
const fs = require('fs');
const path = require('path');
// Property type detection
function getPropertyType(attrName) {
if (attrName.match(/^\(/)) return 0; // (output)="..."
if (attrName.match(/^\[\(/)) return 1; // [(twoWay)]="..."
if (attrName.match(/^\[class\./)) return 2; // [class.x]="..."
if (attrName.match(/^\[attr\./)) return 2; // [attr.x]="..."
if (attrName.match(/^\[[\w\-]+\]/)) return 2; // [input]="..."
if (attrName.match(/^#/)) return 1.5; // #ref
return 3; // attributes
}
// Extract attribute name (before =)
function getAttributeName(attrString) {
const match = attrString.match(/^([^\s=]+)/);
return match ? match[1] : attrString;
}
// Sort attributes by type
function sortAttributes(attributes) {
return attributes.sort((a, b) => {
const nameA = getAttributeName(a);
const nameB = getAttributeName(b);
return getPropertyType(nameA) - getPropertyType(nameB);
});
}
// Format file
function formatFile(content) {
// Pattern: match multi-line elements with attributes
const multiLineAttrRegex = /(<\w+[\w:\-]*)\n((?:\s+[^\s>]+(?:="[^"]*")?\n)*\s+[^\s>]+(?:="[^"]*")?)\n(\s*>)/g;
return content.replace(multiLineAttrRegex, (match, openTag, attrs, closeTag) => {
// Get indentation
const lines = match.split('\n');
const firstLineIndent = lines[0].match(/^(\s*)</)[1];
const attrIndent = lines[1].match(/^(\s+)/)[1];
// Parse attributes
const attrLines = attrs.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Sort attributes
const sorted = sortAttributes(attrLines);
// Rebuild
return openTag + '\n' + sorted.map(attr => attrIndent + attr).join('\n') + '\n' + closeTag;
});
}
// Main
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: node prettier-plugin-wrapper.js <file>');
process.exit(1);
}
const filePath = args[0];
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
let content = fs.readFileSync(filePath, 'utf-8');
const formatted = formatFile(content);
if (formatted !== content) {
fs.writeFileSync(filePath, formatted, 'utf-8');
}
process.exit(0);
}
module.exports = { formatFile };

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Angular Template Property Sorter
* Reorders element properties after Prettier formatting
* Order: outputs → two-way → inputs → attributes
*/
const fs = require('fs');
const path = require('path');
// Property type detection - lower number = higher priority (comes first)
function getPropertyType(attrName) {
if (attrName.match(/^\(/)) return 0; // (output)="..." → outputs first
if (attrName.match(/^\[\(/)) return 1; // [(twoWay)]="..." → two-way second
if (attrName.match(/^\[class\./)) return 2; // [class.x]="..." → inputs third
if (attrName.match(/^\[attr\./)) return 2; // [attr.x]="..." → inputs third
if (attrName.match(/^\[[\w\-]+\]/)) return 2; // [input]="..." → inputs third
if (attrName.match(/^#/)) return 1.5; // #ref → template reference
if (attrName.match(/^@/)) return -1; // @structural → should not appear here
return 3; // everything else → attributes last
}
// Extract attribute name (before =)
function getAttributeName(attrString) {
const match = attrString.match(/^([^\s=]+)/);
return match ? match[1] : attrString;
}
// Sort attributes by type
function sortAttributes(attributes) {
return attributes.sort((a, b) => {
const nameA = getAttributeName(a);
const nameB = getAttributeName(b);
return getPropertyType(nameA) - getPropertyType(nameB);
});
}
// Format file - simple approach: parse and rebuild with sorted attrs
function formatFile(filePath) {
try {
let content = fs.readFileSync(filePath, 'utf-8');
// Pattern: match multi-line elements with attributes
// This is a conservative approach - only reorders complete multi-line attribute blocks
const multiLineAttrRegex = /(<\w+[\w:\-]*)\n((?:\s+[^\s>]+(?:="[^"]*")?\n)*\s+[^\s>]+(?:="[^"]*")?)\n(\s*>)/g;
let modified = false;
const result = content.replace(multiLineAttrRegex, (match, openTag, attrs, closeTag) => {
// Get the indentation from the first line
const lines = match.split('\n');
const firstLineIndent = lines[0].match(/^(\s*)</)[1];
const attrIndent = lines[1].match(/^(\s+)/)[1];
// Parse attributes
const attrLines = attrs.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Sort attributes
const sorted = sortAttributes(attrLines);
// Check if we changed the order
if (sorted.join('\n') !== attrLines.join('\n')) {
modified = true;
}
// Rebuild
return openTag + '\n' + sorted.map(attr => attrIndent + attr).join('\n') + '\n' + closeTag;
});
if (modified) {
fs.writeFileSync(filePath, result, 'utf-8');
console.log(`✓ Sorted properties: ${filePath}`);
return true;
}
return false;
} catch (error) {
console.error(`✗ Error processing ${filePath}:`, error.message);
return false;
}
}
// Main
const args = process.argv.slice(2);
function walkDir(dir, callback) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory() && !filePath.includes('node_modules')) {
walkDir(filePath, callback);
} else if (file.endsWith('.html')) {
callback(filePath);
}
});
}
let processed = 0;
if (args.length > 0) {
args.forEach(file => {
const fullPath = path.resolve(process.cwd(), file);
if (fs.existsSync(fullPath)) {
if (formatFile(fullPath)) {
processed++;
}
}
});
} else {
walkDir(path.join(process.cwd(), 'src/app'), (filePath) => {
if (formatFile(filePath)) {
processed++;
}
});
if (processed > 0) {
console.log(`\nTotal files with properties sorted: ${processed}`);
}
}
process.exit(0);