Now formatted correctly with eslint
This commit is contained in:
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
release/
|
||||
node_modules/
|
||||
**/migrations/**
|
||||
**/generated/**
|
||||
14
.prettierrc.json
Normal file
14
.prettierrc.json
Normal 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"
|
||||
}
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -7,8 +7,11 @@
|
||||
}
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
// "editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "always"
|
||||
}
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
@@ -23,5 +26,5 @@
|
||||
"prettier.printWidth": 150,
|
||||
"prettier.singleAttributePerLine": true,
|
||||
"prettier.htmlWhitespaceSensitivity": "css",
|
||||
"prettier.tabWidth": 4
|
||||
"prettier.tabWidth": 2
|
||||
}
|
||||
|
||||
62
.vscode/tasks.json
vendored
62
.vscode/tasks.json
vendored
@@ -1,41 +1,39 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
"label": "Sort Template Properties",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"tools/sort-template-properties.js",
|
||||
"${file}"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"panel": "shared"
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
"label": "Format HTML on Save",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"prettier",
|
||||
"--write",
|
||||
"${file}"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"panel": "shared"
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
14
angular.json
14
angular.json
@@ -3,7 +3,10 @@
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
"analytics": false,
|
||||
"schematicCollections": [
|
||||
"angular-eslint"
|
||||
]
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
@@ -100,6 +103,15 @@
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
239
eslint.config.js
239
eslint.config.js
@@ -1,39 +1,57 @@
|
||||
// ESLint Flat Config for Weaver
|
||||
const eslint = require('@eslint/js');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const angular = require('angular-eslint');
|
||||
const stylisticTs = require('@stylistic/eslint-plugin-ts');
|
||||
const stylisticJs = require('@stylistic/eslint-plugin-js');
|
||||
const newlines = require('eslint-plugin-import-newlines');
|
||||
|
||||
// Inline plugin: ban en dash (–, U+2013) and em dash (—, U+2014) from source files
|
||||
const noDashPlugin = {
|
||||
rules: {
|
||||
'no-unicode-dashes': {
|
||||
meta: { fixable: 'code' },
|
||||
create(context) {
|
||||
const BANNED = [
|
||||
{ char: '\u2013', name: 'en dash (–)' },
|
||||
{ char: '\u2014', name: 'em dash (—)' }
|
||||
];
|
||||
return {
|
||||
Program() {
|
||||
const src = context.getSourceCode().getText();
|
||||
for (const { char, name } of BANNED) {
|
||||
let idx = src.indexOf(char);
|
||||
while (idx !== -1) {
|
||||
const start = idx;
|
||||
const end = idx + char.length;
|
||||
context.report({
|
||||
loc: context.getSourceCode().getLocFromIndex(idx),
|
||||
message: `Unicode ${name} is not allowed. Use a regular hyphen (-) instead.`,
|
||||
fix(fixer) {
|
||||
return fixer.replaceTextRange([start, end], '-');
|
||||
}
|
||||
});
|
||||
idx = src.indexOf(char, idx + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/generated/*',
|
||||
'dist/**',
|
||||
'dist-electron/**',
|
||||
'.angular/**',
|
||||
'**/migrations/**',
|
||||
'release/**',
|
||||
'src/index.html',
|
||||
'server/**'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['src/app/core/services/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/member-ordering': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-invalid-void-type': 'off',
|
||||
'@typescript-eslint/prefer-for-of': 'off',
|
||||
'id-length': 'off',
|
||||
'max-statements-per-line': 'off'
|
||||
}
|
||||
ignores: ['**/generated/*','dist/**', '**/migrations/**', 'release/**']
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
plugins: {
|
||||
'@stylistic/ts': stylisticTs,
|
||||
'@stylistic/js': stylisticJs
|
||||
'@stylistic/js': stylisticJs,
|
||||
'import-newlines': newlines,
|
||||
'no-dashes': noDashPlugin
|
||||
},
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
@@ -44,92 +62,38 @@ module.exports = tseslint.config(
|
||||
],
|
||||
processor: angular.processInlineTemplates,
|
||||
rules: {
|
||||
'no-dashes/no-unicode-dashes': 'error',
|
||||
'@typescript-eslint/no-extraneous-class': 'off',
|
||||
'@angular-eslint/component-class-suffix': ['error', { suffixes: ['Component', 'Page', 'Stub'] }],
|
||||
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
|
||||
'@angular-eslint/directive-class-suffix': 'error',
|
||||
'@typescript-eslint/explicit-module-boundry-types': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }],
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||
'@typescript-eslint/explicit-member-accessibility': ['error',{ accessibility: 'no-public' }],
|
||||
'@typescript-eslint/array-type': ['error',{ default: 'array' }],
|
||||
'@typescript-eslint/consistent-type-definitions': 'error',
|
||||
'@typescript-eslint/dot-notation': 'off',
|
||||
'@stylistic/ts/indent': [
|
||||
'error',
|
||||
2,
|
||||
{
|
||||
ignoredNodes: [
|
||||
'TSTypeParameterInstantiation',
|
||||
'FunctionExpression > .params[decorators.length > 0]',
|
||||
'FunctionExpression > .params > :matches(Decorator, :not(:first-child))',
|
||||
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
|
||||
],
|
||||
SwitchCase: 1
|
||||
}
|
||||
],
|
||||
'@stylistic/ts/member-delimiter-style': [
|
||||
'error',
|
||||
{
|
||||
multiline: { delimiter: 'semi', requireLast: true },
|
||||
singleline: { delimiter: 'semi', requireLast: false }
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/member-ordering': [
|
||||
'error',
|
||||
{
|
||||
default: [
|
||||
'signature',
|
||||
'call-signature',
|
||||
'public-static-field',
|
||||
'protected-static-field',
|
||||
'private-static-field',
|
||||
'#private-static-field',
|
||||
'public-decorated-field',
|
||||
'protected-decorated-field',
|
||||
'private-decorated-field',
|
||||
'public-instance-field',
|
||||
'protected-instance-field',
|
||||
'private-instance-field',
|
||||
'#private-instance-field',
|
||||
'public-abstract-field',
|
||||
'protected-abstract-field',
|
||||
'public-field',
|
||||
'protected-field',
|
||||
'private-field',
|
||||
'#private-field',
|
||||
'static-field',
|
||||
'instance-field',
|
||||
'abstract-field',
|
||||
'decorated-field',
|
||||
'field',
|
||||
'static-initialization',
|
||||
'public-constructor',
|
||||
'protected-constructor',
|
||||
'private-constructor',
|
||||
'constructor',
|
||||
'public-static-method',
|
||||
'protected-static-method',
|
||||
'private-static-method',
|
||||
'#private-static-method',
|
||||
'public-decorated-method',
|
||||
'protected-decorated-method',
|
||||
'private-decorated-method',
|
||||
'public-instance-method',
|
||||
'protected-instance-method',
|
||||
'private-instance-method',
|
||||
'#private-instance-method',
|
||||
'public-abstract-method',
|
||||
'protected-abstract-method',
|
||||
'public-method',
|
||||
'protected-method',
|
||||
'private-method',
|
||||
'#private-method',
|
||||
'static-method',
|
||||
'instance-method',
|
||||
'abstract-method',
|
||||
'decorated-method',
|
||||
'method'
|
||||
]
|
||||
}
|
||||
],
|
||||
'@stylistic/ts/indent': ['error',2,{ ignoredNodes:[
|
||||
'TSTypeParameterInstantation',
|
||||
'FunctionExpression > .params[decorators.length > 0]',
|
||||
'FunctionExpression > .params > :matches(Decorator, :not(:first-child))',
|
||||
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
|
||||
], SwitchCase:1 }],
|
||||
'@stylistic/ts/member-delimiter-style': ['error',{ multiline:{ delimiter:'semi', requireLast:true }, singleline:{ delimiter:'semi', requireLast:false } }],
|
||||
'@typescript-eslint/member-ordering': ['error',{ default:[
|
||||
'signature','call-signature',
|
||||
'public-static-field','protected-static-field','private-static-field','#private-static-field',
|
||||
'public-decorated-field','protected-decorated-field','private-decorated-field',
|
||||
'public-instance-field','protected-instance-field','private-instance-field','#private-instance-field',
|
||||
'public-abstract-field','protected-abstract-field',
|
||||
'public-field','protected-field','private-field','#private-field',
|
||||
'static-field','instance-field','abstract-field','decorated-field','field','static-initialization',
|
||||
'public-constructor','protected-constructor','private-constructor','constructor',
|
||||
'public-static-method','protected-static-method','private-static-method','#private-static-method',
|
||||
'public-decorated-method','protected-decorated-method','private-decorated-method',
|
||||
'public-instance-method','protected-instance-method','private-instance-method','#private-instance-method',
|
||||
'public-abstract-method','protected-abstract-method','public-method','protected-method','private-method','#private-method',
|
||||
'static-method','instance-method','abstract-method','decorated-method','method'
|
||||
] }],
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-empty-interface': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
@@ -137,23 +101,23 @@ module.exports = tseslint.config(
|
||||
'@typescript-eslint/no-namespace': 'error',
|
||||
'@typescript-eslint/prefer-namespace-keyword': 'error',
|
||||
'@typescript-eslint/no-unused-expressions': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', ignoreRestSiblings: true }],
|
||||
'@typescript-eslint/no-unused-vars': ['error',{ argsIgnorePattern: '^_', ignoreRestSiblings: true }],
|
||||
'@typescript-eslint/no-var-requires': 'error',
|
||||
'@typescript-eslint/prefer-for-of': 'error',
|
||||
'@typescript-eslint/prefer-function-type': 'error',
|
||||
'@stylistic/ts/quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'@stylistic/ts/semi': ['error', 'always'],
|
||||
'@stylistic/ts/quotes': ['error','single',{ avoidEscape:true }],
|
||||
'@stylistic/ts/semi': ['error','always'],
|
||||
'@stylistic/ts/type-annotation-spacing': 'error',
|
||||
'@typescript-eslint/unified-signatures': 'error',
|
||||
'@stylistic/js/array-bracket-spacing': 'error',
|
||||
'@stylistic/ts/comma-dangle': ['error', 'never'],
|
||||
'@stylistic/ts/comma-dangle': ['error','never'],
|
||||
'@stylistic/ts/comma-spacing': 'error',
|
||||
'@stylistic/js/comma-style': 'error',
|
||||
complexity: ['warn', { max: 20 }],
|
||||
curly: 'off',
|
||||
'complexity': ['warn',{ max:20 }],
|
||||
'curly': 'off',
|
||||
'eol-last': 'error',
|
||||
'id-denylist': ['warn', 'e', 'cb', 'i', 'x', 'c', 'y', 'any', 'string', 'String', 'Undefined', 'undefined', 'callback'],
|
||||
'max-len': ['error', { code: 150, ignoreComments: true }],
|
||||
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
|
||||
'max-len': ['error',{ code:150, ignoreComments:true }],
|
||||
'new-parens': 'error',
|
||||
'newline-per-chained-call': 'error',
|
||||
'no-bitwise': 'off',
|
||||
@@ -161,47 +125,70 @@ module.exports = tseslint.config(
|
||||
'no-empty': 'off',
|
||||
'no-eval': 'error',
|
||||
'@stylistic/js/no-multi-spaces': 'error',
|
||||
'@stylistic/js/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }],
|
||||
'@stylistic/js/no-multiple-empty-lines': ['error',{ max:1, maxEOF:1 }],
|
||||
'no-new-wrappers': 'error',
|
||||
'no-restricted-imports': ['error', 'rxjs/Rx'],
|
||||
'no-restricted-imports': ['error','rxjs/Rx'],
|
||||
'no-throw-literal': 'error',
|
||||
'no-trailing-spaces': 'error',
|
||||
'no-undef-init': 'error',
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-var': 'error',
|
||||
'one-var': ['error', 'never'],
|
||||
'one-var': ['error','never'],
|
||||
'prefer-const': 'error',
|
||||
'@stylistic/ts/space-before-blocks': 'error',
|
||||
'@stylistic/js/space-before-function-paren': ['error', { anonymous: 'never', asyncArrow: 'always', named: 'never' }],
|
||||
'@stylistic/js/space-before-function-paren': ['error',{ anonymous:'never', asyncArrow:'always', named:'never' }],
|
||||
'@stylistic/ts/space-infix-ops': 'error',
|
||||
'@stylistic/js/space-in-parens': 'error',
|
||||
'@stylistic/js/space-unary-ops': 'error',
|
||||
'@stylistic/js/spaced-comment': ['error', 'always', { markers: ['/'] }],
|
||||
'@stylistic/js/block-spacing': ['error', 'always'],
|
||||
'@stylistic/js/spaced-comment': ['error','always',{ markers:['/'] }],
|
||||
"import-newlines/enforce": [
|
||||
"error",
|
||||
2
|
||||
],
|
||||
// Require spaces inside single-line blocks: { stmt; }
|
||||
'@stylistic/js/block-spacing': ['error','always'],
|
||||
|
||||
// Disallow single-line if statements but allow body on the next line (with or without braces)
|
||||
// Examples allowed:
|
||||
// if (condition)\n return true;
|
||||
// if (condition)\n {\n return true;\n }
|
||||
'nonblock-statement-body-position': ['error', 'below'],
|
||||
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
||||
'max-statements-per-line': ['error', { max: 1 }],
|
||||
// Prevent single-character identifiers for variables/params; do not check object property names
|
||||
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
|
||||
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
||||
'padding-line-between-statements': [
|
||||
'error',
|
||||
// Ensure blank lines around standalone if statements within the same scope
|
||||
{ blankLine: 'always', prev: '*', next: 'if' },
|
||||
{ blankLine: 'always', prev: 'if', next: '*' },
|
||||
// Keep clear separation around any block-like statement (if, function, class, switch, try, etc.)
|
||||
{ blankLine: 'always', prev: '*', next: 'block-like' },
|
||||
{ blankLine: 'always', prev: 'block-like', next: '*' },
|
||||
{ blankLine: 'always', prev: 'function', next: '*' },
|
||||
{ blankLine: 'always', prev: 'class', next: '*' },
|
||||
// Always require a blank line after functions (and multiline expressions)
|
||||
{ blankLine: 'always', prev: ['function', 'multiline-expression'], next: '*' },
|
||||
// Always require a blank line after class declarations (and multiline expressions)
|
||||
{ blankLine: 'always', prev: ['class', 'multiline-expression'], next: '*' },
|
||||
// Always require a blank line after groups of variable declarations
|
||||
{ blankLine: 'always', prev: 'const', next: '*' },
|
||||
{ blankLine: 'always', prev: 'let', next: '*' },
|
||||
{ blankLine: 'always', prev: 'var', next: '*' },
|
||||
// But never require a blank line between a series of variable declarations of the same kind
|
||||
{ blankLine: 'never', prev: 'const', next: 'const' },
|
||||
{ blankLine: 'never', prev: 'let', next: 'let' },
|
||||
{ blankLine: 'never', prev: 'var', next: 'var' }
|
||||
]
|
||||
}
|
||||
},
|
||||
// HTML template formatting rules (external Angular templates only)
|
||||
{
|
||||
files: ['src/app/**/*.html'],
|
||||
plugins: { 'no-dashes': noDashPlugin },
|
||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||
rules: {
|
||||
'no-dashes/no-unicode-dashes': 'error',
|
||||
// Angular template best practices
|
||||
'@angular-eslint/template/button-has-type': 'warn',
|
||||
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
|
||||
'@angular-eslint/template/eqeqeq': 'error',
|
||||
@@ -210,9 +197,13 @@ module.exports = tseslint.config(
|
||||
'@angular-eslint/template/prefer-self-closing-tags': 'warn',
|
||||
'@angular-eslint/template/use-track-by-function': 'warn',
|
||||
'@angular-eslint/template/no-negated-async': 'warn',
|
||||
'@angular-eslint/template/no-call-expression': 'off'
|
||||
}
|
||||
}
|
||||
'@angular-eslint/template/no-call-expression': 'off', // Allow method calls in templates
|
||||
// Note: attributes-order is disabled in favor of Prettier handling formatting
|
||||
// Prettier uses singleAttributePerLine to enforce property grouping
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// IMPORTANT: Formatting is handled by Prettier; ESLint validates logic/accessibility.
|
||||
// IMPORTANT: Formatting is handled by Prettier, not ESLint
|
||||
// ESLint validates logic/accessibility, Prettier handles formatting
|
||||
// Enable format on save in VS Code settings to use Prettier automatically
|
||||
|
||||
29
package.json
29
package.json
@@ -29,19 +29,11 @@
|
||||
"build:prod:all": "npm run build:prod && cd server && npm run build",
|
||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||
"dev": "npm run electron:full",
|
||||
"lint": "eslint . --ext .ts,.html"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||
"format": "prettier --write \"src/app/**/*.html\"",
|
||||
"format:check": "prettier --check \"src/app/**/*.html\"",
|
||||
"sort:props": "node tools/sort-template-properties.js"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@10.9.2",
|
||||
@@ -84,17 +76,22 @@
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "^21.2.0",
|
||||
"angular-eslint": "21.2.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import-newlines": "^1.4.1",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"glob": "^10.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"typescript-eslint": "8.50.1",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,8 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
isDevMode
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideStore } from '@ngrx/store';
|
||||
@@ -26,7 +30,12 @@ export const appConfig: ApplicationConfig = {
|
||||
users: usersReducer,
|
||||
rooms: roomsReducer
|
||||
}),
|
||||
provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]),
|
||||
provideEffects([
|
||||
MessagesEffects,
|
||||
MessagesSyncEffects,
|
||||
UsersEffects,
|
||||
RoomsEffects
|
||||
]),
|
||||
provideStoreDevtools({
|
||||
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
||||
logOnly: !isDevMode(),
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */
|
||||
import { Component, OnInit, inject, HostListener } from '@angular/core';
|
||||
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
inject,
|
||||
HostListener
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Router,
|
||||
RouterOutlet,
|
||||
NavigationEnd
|
||||
} from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DEFAULT_MAX_USERS = 50;
|
||||
/** Default audio bitrate in kbps for voice chat. */
|
||||
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
||||
|
||||
/** Default volume level (0–100). */
|
||||
/** Default volume level (0-100). */
|
||||
export const DEFAULT_VOLUME = 100;
|
||||
|
||||
/** Default search debounce time in milliseconds. */
|
||||
|
||||
@@ -306,6 +306,7 @@ export type ChatEventType =
|
||||
| 'room-deleted'
|
||||
| 'room-settings-update'
|
||||
| 'voice-state'
|
||||
| 'chat-inventory-request'
|
||||
| 'voice-state-request'
|
||||
| 'state-request'
|
||||
| 'screen-state'
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
|
||||
import { Injectable, inject, signal, effect } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
signal,
|
||||
effect
|
||||
} from '@angular/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { WebRTCService } from './webrtc.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -168,7 +173,7 @@ export class AttachmentService {
|
||||
|
||||
/**
|
||||
* Register attachment metadata received via message sync
|
||||
* (content is not yet available — only metadata).
|
||||
* (content is not yet available - only metadata).
|
||||
*
|
||||
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
||||
*/
|
||||
@@ -184,7 +189,9 @@ export class AttachmentService {
|
||||
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
|
||||
|
||||
if (!alreadyKnown) {
|
||||
const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 };
|
||||
const attachment: Attachment = { ...meta,
|
||||
available: false,
|
||||
receivedBytes: 0 };
|
||||
|
||||
existing.push(attachment);
|
||||
newAttachments.push(attachment);
|
||||
@@ -228,7 +235,7 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a `file-not-found` response — try the next available peer.
|
||||
* Handle a `file-not-found` response - try the next available peer.
|
||||
*/
|
||||
handleFileNotFound(payload: any): void {
|
||||
const { messageId, fileId } = payload;
|
||||
@@ -428,6 +435,7 @@ export class AttachmentService {
|
||||
attachment.speedBps =
|
||||
EWMA_PREVIOUS_WEIGHT * previousSpeed +
|
||||
EWMA_CURRENT_WEIGHT * instantaneousBps;
|
||||
|
||||
attachment.lastUpdateMs = now;
|
||||
|
||||
this.touch(); // trigger UI update for progress bars
|
||||
@@ -600,7 +608,7 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a `file-cancel` from the requester — record the
|
||||
* Handle a `file-cancel` from the requester - record the
|
||||
* cancellation so the streaming loop breaks early.
|
||||
*/
|
||||
handleFileCancel(payload: any): void {
|
||||
@@ -690,6 +698,7 @@ export class AttachmentService {
|
||||
messageId,
|
||||
fileId
|
||||
} as any);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -923,7 +932,8 @@ export class AttachmentService {
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
|
||||
for (const record of allRecords) {
|
||||
const attachment: Attachment = { ...record, available: false };
|
||||
const attachment: Attachment = { ...record,
|
||||
available: false };
|
||||
const bucket = grouped.get(record.messageId) ?? [];
|
||||
|
||||
bucket.push(attachment);
|
||||
@@ -949,7 +959,8 @@ export class AttachmentService {
|
||||
const existing = this.attachmentsByMessage.get(meta.messageId) ?? [];
|
||||
|
||||
if (!existing.find((entry) => entry.id === meta.id)) {
|
||||
const attachment: Attachment = { ...meta, available: false };
|
||||
const attachment: Attachment = { ...meta,
|
||||
available: false };
|
||||
|
||||
existing.push(attachment);
|
||||
this.attachmentsByMessage.set(meta.messageId, existing);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
import {
|
||||
Message,
|
||||
User,
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../models';
|
||||
|
||||
/** IndexedDB database name for the MetoYou application. */
|
||||
const DATABASE_NAME = 'metoyou';
|
||||
/** IndexedDB schema version — bump when adding/changing object stores. */
|
||||
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||
const DATABASE_VERSION = 2;
|
||||
/** Names of every object store used by the application. */
|
||||
const STORE_MESSAGES = 'messages';
|
||||
@@ -77,7 +83,8 @@ export class BrowserDatabaseService {
|
||||
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
|
||||
|
||||
if (existing) {
|
||||
await this.put(STORE_MESSAGES, { ...existing, ...updates });
|
||||
await this.put(STORE_MESSAGES, { ...existing,
|
||||
...updates });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +167,8 @@ export class BrowserDatabaseService {
|
||||
|
||||
/** Store which user ID is considered "current" (logged-in). */
|
||||
async setCurrentUserId(userId: string): Promise<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);
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
import {
|
||||
inject,
|
||||
Injectable,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Message,
|
||||
User,
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../models';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
@@ -12,7 +22,7 @@ import { ElectronDatabaseService } from './electron-database.service';
|
||||
* - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
||||
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
|
||||
*
|
||||
* All consumers inject `DatabaseService` — the underlying storage engine
|
||||
* All consumers inject `DatabaseService` - the underlying storage engine
|
||||
* is selected automatically.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
import {
|
||||
Message,
|
||||
User,
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* Database service for the Electron (desktop) runtime.
|
||||
|
||||
@@ -18,7 +18,7 @@ const AUDIO_BASE = '/assets/audio';
|
||||
const AUDIO_EXT = 'wav';
|
||||
/** localStorage key for persisting notification volume. */
|
||||
const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume';
|
||||
/** Default notification volume (0 – 1). */
|
||||
/** Default notification volume (0 - 1). */
|
||||
const DEFAULT_VOLUME = 0.2;
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export class NotificationAudioService {
|
||||
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
||||
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
||||
|
||||
/** Reactive notification volume (0 – 1), persisted to localStorage. */
|
||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||
readonly notificationVolume = signal(this.loadVolume());
|
||||
|
||||
constructor() {
|
||||
@@ -88,10 +88,10 @@ export class NotificationAudioService {
|
||||
* Play a sound effect at the current notification volume.
|
||||
*
|
||||
* If playback fails (e.g. browser autoplay policy) the error is
|
||||
* silently swallowed — sound effects are non-critical.
|
||||
* silently swallowed - sound effects are non-critical.
|
||||
*
|
||||
* @param sound - The {@link AppSound} to play.
|
||||
* @param volumeOverride - Optional explicit volume (0 – 1). When omitted
|
||||
* @param volumeOverride - Optional explicit volume (0 - 1). When omitted
|
||||
* the persisted {@link notificationVolume} is used.
|
||||
*/
|
||||
play(sound: AppSound, volumeOverride?: number): void {
|
||||
|
||||
@@ -15,6 +15,7 @@ export class PlatformService {
|
||||
constructor() {
|
||||
this.isElectron =
|
||||
typeof window !== 'undefined' && !!(window as any).electronAPI;
|
||||
|
||||
this.isBrowser = !this.isElectron;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, throwError, forkJoin } from 'rxjs';
|
||||
import {
|
||||
Observable,
|
||||
of,
|
||||
throwError,
|
||||
forkJoin
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerInfo, JoinRequest, User } from '../models';
|
||||
import {
|
||||
ServerInfo,
|
||||
JoinRequest,
|
||||
User
|
||||
} from '../models';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
@@ -137,6 +150,7 @@ export class ServerDirectoryService {
|
||||
isActive: endpoint.id === endpointId
|
||||
}))
|
||||
);
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
@@ -148,9 +162,12 @@ export class ServerDirectoryService {
|
||||
): void {
|
||||
this._servers.update((endpoints) =>
|
||||
endpoints.map((endpoint) =>
|
||||
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint
|
||||
endpoint.id === endpointId ? { ...endpoint,
|
||||
status,
|
||||
latency } : endpoint
|
||||
)
|
||||
);
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
@@ -541,7 +558,8 @@ export class ServerDirectoryService {
|
||||
|
||||
endpoints = endpoints.map((endpoint) => {
|
||||
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
|
||||
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) };
|
||||
return { ...endpoint,
|
||||
url: endpoint.url.replace(/^https?/, expectedProtocol) };
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
@@ -556,7 +574,8 @@ export class ServerDirectoryService {
|
||||
|
||||
/** Create and persist the built-in default endpoint. */
|
||||
private initialiseDefaultEndpoint(): void {
|
||||
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() };
|
||||
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT,
|
||||
id: uuidv4() };
|
||||
|
||||
this._servers.set([defaultEndpoint]);
|
||||
this.saveEndpoints();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
|
||||
/** Default timeout (ms) for the NTP-style HTTP sync request. */
|
||||
const DEFAULT_SYNC_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* VoiceActivityService — monitors audio levels for local microphone
|
||||
* VoiceActivityService - monitors audio levels for local microphone
|
||||
* and remote peer streams, exposing per-user "speaking" state as
|
||||
* reactive Angular signals.
|
||||
*
|
||||
@@ -9,19 +9,26 @@
|
||||
* // speaking() => true when the user's audio level exceeds the threshold
|
||||
*
|
||||
* const volume = voiceActivity.volume(userId);
|
||||
* // volume() => normalised 0–1 audio level
|
||||
* // volume() => normalised 0-1 audio level
|
||||
* ```
|
||||
*
|
||||
* Internally uses the Web Audio API ({@link AudioContext} +
|
||||
* {@link AnalyserNode}) per tracked stream, with a single
|
||||
* `requestAnimationFrame` poll loop.
|
||||
*/
|
||||
import { Injectable, signal, computed, inject, OnDestroy, Signal } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnDestroy,
|
||||
Signal
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { WebRTCService } from './webrtc.service';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */
|
||||
|
||||
/** RMS volume threshold (0–1) above which a user counts as "speaking". */
|
||||
/** RMS volume threshold (0-1) above which a user counts as "speaking". */
|
||||
const SPEAKING_THRESHOLD = 0.015;
|
||||
/** How many consecutive silent frames before we flip speaking → false. */
|
||||
const SILENT_FRAME_GRACE = 8;
|
||||
@@ -38,7 +45,7 @@ interface TrackedStream {
|
||||
analyser: AnalyserNode;
|
||||
/** Reusable buffer for `getByteTimeDomainData`. */
|
||||
dataArray: Uint8Array<ArrayBuffer>;
|
||||
/** Writable signal for the normalised volume (0–1). */
|
||||
/** Writable signal for the normalised volume (0-1). */
|
||||
volumeSignal: ReturnType<typeof signal<number>>;
|
||||
/** Writable signal for speaking state. */
|
||||
speakingSignal: ReturnType<typeof signal<boolean>>;
|
||||
@@ -123,7 +130,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a read-only signal with the normalised (0–1) volume
|
||||
* Returns a read-only signal with the normalised (0-1) volume
|
||||
* for the given user.
|
||||
*/
|
||||
volume(userId: string): Signal<number> {
|
||||
@@ -160,7 +167,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
analyser.fftSize = FFT_SIZE;
|
||||
|
||||
source.connect(analyser);
|
||||
// Do NOT connect analyser to ctx.destination — we don't want to
|
||||
// Do NOT connect analyser to ctx.destination - we don't want to
|
||||
// double-play audio; playback is handled elsewhere.
|
||||
|
||||
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
|
||||
@@ -226,7 +233,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
// Compute RMS volume from time-domain data (values 0–255, centred at 128).
|
||||
// Compute RMS volume from time-domain data (values 0-255, centred at 128).
|
||||
let sumSquares = 0;
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
@@ -271,6 +278,7 @@ export class VoiceActivityService implements OnDestroy {
|
||||
this.tracked.forEach((entry, id) => {
|
||||
map.set(id, entry.speakingSignal());
|
||||
});
|
||||
|
||||
this._speakingMap.set(map);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* VoiceLevelingService — Angular service that manages the
|
||||
* VoiceLevelingService - Angular service that manages the
|
||||
* per-speaker voice leveling (AGC) system.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
@@ -17,7 +17,7 @@
|
||||
*
|
||||
* 4. Provides an `enable` / `disable` / `disableAll` API that
|
||||
* the voice-controls component uses to insert and remove the
|
||||
* AGC pipeline from the remote audio playback chain — mirroring
|
||||
* AGC pipeline from the remote audio playback chain - mirroring
|
||||
* the {@link NoiseReductionManager} toggle pattern.
|
||||
*
|
||||
* 5. Fires a callback when the user toggles the enabled state so
|
||||
@@ -26,7 +26,12 @@
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import {
|
||||
VoiceLevelingManager,
|
||||
VoiceLevelingSettings,
|
||||
@@ -167,7 +172,7 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
* Set the post-AGC volume for a specific speaker.
|
||||
*
|
||||
* @param peerId The speaker's peer ID.
|
||||
* @param volume Normalised volume (0–1).
|
||||
* @param volume Normalised volume (0-1).
|
||||
*/
|
||||
setSpeakerVolume(peerId: string, volume: number): void {
|
||||
this.manager.setSpeakerVolume(peerId, volume);
|
||||
@@ -176,7 +181,7 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
/**
|
||||
* Set the master volume applied after AGC to all speakers.
|
||||
*
|
||||
* @param volume Normalised volume (0–1).
|
||||
* @param volume Normalised volume (0-1).
|
||||
*/
|
||||
setMasterVolume(volume: number): void {
|
||||
this.manager.setMasterVolume(volume);
|
||||
@@ -222,7 +227,7 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
STORAGE_KEY_VOICE_LEVELING_SETTINGS,
|
||||
JSON.stringify(settings)
|
||||
);
|
||||
} catch { /* localStorage unavailable — ignore */ }
|
||||
} catch { /* localStorage unavailable - ignore */ }
|
||||
}
|
||||
|
||||
/** Load settings from localStorage and apply to the manager. */
|
||||
@@ -264,7 +269,7 @@ export class VoiceLevelingService implements OnDestroy {
|
||||
speed: this._speed(),
|
||||
noiseGate: this._noiseGate()
|
||||
});
|
||||
} catch { /* corrupted data — use defaults */ }
|
||||
} catch { /* corrupted data - use defaults */ }
|
||||
}
|
||||
|
||||
/* ── Cleanup ─────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
@@ -31,7 +36,7 @@ export interface VoiceSessionInfo {
|
||||
* navigation so that floating voice controls remain visible when
|
||||
* the user is browsing a different server or view.
|
||||
*
|
||||
* This service is purely a UI-state tracker — actual WebRTC
|
||||
* This service is purely a UI-state tracker - actual WebRTC
|
||||
* voice management lives in {@link WebRTCService} and its managers.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -132,6 +137,7 @@ export class VoiceSessionService {
|
||||
} as any
|
||||
})
|
||||
);
|
||||
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
/**
|
||||
* WebRTCService — thin Angular service that composes specialised managers.
|
||||
* WebRTCService - thin Angular service that composes specialised managers.
|
||||
*
|
||||
* Each concern lives in its own file under `./webrtc/`:
|
||||
* • SignalingManager – WebSocket lifecycle & reconnection
|
||||
* • PeerConnectionManager – RTCPeerConnection, offers/answers, ICE, data channels
|
||||
* • MediaManager – mic voice, mute, deafen, bitrate
|
||||
* • ScreenShareManager – screen capture & mixed audio
|
||||
* • WebRTCLogger – debug / diagnostic logging
|
||||
* • SignalingManager - WebSocket lifecycle & reconnection
|
||||
* • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
|
||||
* • MediaManager - mic voice, mute, deafen, bitrate
|
||||
* • ScreenShareManager - screen capture & mixed audio
|
||||
* • WebRTCLogger - debug / diagnostic logging
|
||||
*
|
||||
* This file wires them together and exposes a public API that is
|
||||
* identical to the old monolithic service so consumers don't change.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */
|
||||
import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SignalingMessage, ChatEvent } from '../models';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
|
||||
import {
|
||||
// Managers
|
||||
SignalingManager,
|
||||
PeerConnectionManager,
|
||||
MediaManager,
|
||||
ScreenShareManager,
|
||||
WebRTCLogger,
|
||||
// Types
|
||||
IdentifyCredentials,
|
||||
JoinedServerInfo,
|
||||
VoiceStateSnapshot,
|
||||
LatencyProfile,
|
||||
// Constants
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER,
|
||||
@@ -255,6 +258,7 @@ export class WebRTCService implements OnDestroy {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
this.peerManager.createPeerConnection(user.oderId, true);
|
||||
this.peerManager.createAndSendOffer(user.oderId);
|
||||
|
||||
@@ -273,6 +277,7 @@ export class WebRTCService implements OnDestroy {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case SIGNALING_TYPE_USER_LEFT:
|
||||
@@ -337,7 +342,9 @@ export class WebRTCService implements OnDestroy {
|
||||
});
|
||||
|
||||
for (const peerId of peersToClose) {
|
||||
this.logger.info('Closing peer from different server', { peerId, currentServer: serverId });
|
||||
this.logger.info('Closing peer from different server', { peerId,
|
||||
currentServer: serverId });
|
||||
|
||||
this.peerManager.removePeer(peerId);
|
||||
this.peerServerMap.delete(peerId);
|
||||
}
|
||||
@@ -355,7 +362,7 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PUBLIC API – matches the old monolithic service's interface
|
||||
// PUBLIC API - matches the old monolithic service's interface
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
@@ -414,8 +421,12 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param displayName - The user's display name.
|
||||
*/
|
||||
identify(oderId: string, displayName: string): void {
|
||||
this.lastIdentifyCredentials = { oderId, displayName };
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName });
|
||||
this.lastIdentifyCredentials = { oderId,
|
||||
displayName };
|
||||
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId,
|
||||
displayName });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,9 +436,12 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param userId - The local user ID.
|
||||
*/
|
||||
joinRoom(roomId: string, userId: string): void {
|
||||
this.lastJoinedServer = { serverId: roomId, userId };
|
||||
this.lastJoinedServer = { serverId: roomId,
|
||||
userId };
|
||||
|
||||
this.memberServerIds.add(roomId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId: roomId });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,10 +452,13 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param userId - The local user ID.
|
||||
*/
|
||||
switchServer(serverId: string, userId: string): void {
|
||||
this.lastJoinedServer = { serverId, userId };
|
||||
this.lastJoinedServer = { serverId,
|
||||
userId };
|
||||
|
||||
if (this.memberServerIds.has(serverId)) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||
serverId });
|
||||
|
||||
this.logger.info('Viewed server (already joined)', {
|
||||
serverId,
|
||||
userId,
|
||||
@@ -449,7 +466,9 @@ export class WebRTCService implements OnDestroy {
|
||||
});
|
||||
} else {
|
||||
this.memberServerIds.add(serverId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId });
|
||||
|
||||
this.logger.info('Joined new server via switch', {
|
||||
serverId,
|
||||
userId,
|
||||
@@ -469,7 +488,9 @@ export class WebRTCService implements OnDestroy {
|
||||
leaveRoom(serverId?: string): void {
|
||||
if (serverId) {
|
||||
this.memberServerIds.delete(serverId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId });
|
||||
|
||||
this.logger.info('Left server', { serverId });
|
||||
|
||||
if (this.memberServerIds.size === 0) {
|
||||
@@ -480,8 +501,10 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
|
||||
this.memberServerIds.forEach((sid) => {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId: sid });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId: sid });
|
||||
});
|
||||
|
||||
this.memberServerIds.clear();
|
||||
this.fullCleanup();
|
||||
}
|
||||
@@ -619,7 +642,7 @@ export class WebRTCService implements OnDestroy {
|
||||
/**
|
||||
* Set the output volume for remote audio playback.
|
||||
*
|
||||
* @param volume - Normalised volume (0–1).
|
||||
* @param volume - Normalised volume (0-1).
|
||||
*/
|
||||
setOutputVolume(volume: number): void {
|
||||
this.mediaManager.setOutputVolume(volume);
|
||||
|
||||
@@ -114,7 +114,7 @@ export class MediaManager {
|
||||
getIsSelfDeafened(): boolean {
|
||||
return this.isSelfDeafened;
|
||||
}
|
||||
/** Current remote audio output volume (normalised 0–1). */
|
||||
/** Current remote audio output volume (normalised 0-1). */
|
||||
getRemoteAudioVolume(): number {
|
||||
return this.remoteAudioVolume;
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export class MediaManager {
|
||||
*/
|
||||
async setLocalStream(stream: MediaStream): Promise<void> {
|
||||
this.rawMicStream = stream;
|
||||
this.logger.info('setLocalStream — noiseReductionDesired =', this._noiseReductionDesired);
|
||||
this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired);
|
||||
|
||||
// Pipe through the denoiser when the user wants noise reduction
|
||||
if (this._noiseReductionDesired) {
|
||||
@@ -259,6 +259,7 @@ export class MediaManager {
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !newMutedState;
|
||||
});
|
||||
|
||||
this.isMicMuted = newMutedState;
|
||||
}
|
||||
}
|
||||
@@ -299,8 +300,9 @@ export class MediaManager {
|
||||
if (shouldEnable) {
|
||||
if (!this.rawMicStream) {
|
||||
this.logger.warn(
|
||||
'Cannot enable noise reduction — no mic stream yet (will apply on connect)'
|
||||
'Cannot enable noise reduction - no mic stream yet (will apply on connect)'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { WebRTCLogger } from './webrtc-logger';
|
||||
|
||||
/** Name used to register / instantiate the AudioWorklet processor. */
|
||||
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
|
||||
/** RNNoise is trained on 48 kHz audio — the AudioContext must match. */
|
||||
/** RNNoise is trained on 48 kHz audio - the AudioContext must match. */
|
||||
const RNNOISE_SAMPLE_RATE = 48_000;
|
||||
/**
|
||||
* Relative path (from the served application root) to the **bundled**
|
||||
|
||||
@@ -123,7 +123,8 @@ export class PeerConnectionManager {
|
||||
* @returns The newly-created {@link PeerData} record.
|
||||
*/
|
||||
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
||||
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
||||
this.logger.info('Creating peer connection', { remotePeerId,
|
||||
isInitiator });
|
||||
|
||||
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
|
||||
@@ -136,6 +137,7 @@ export class PeerConnectionManager {
|
||||
remotePeerId,
|
||||
candidateType: (event.candidate as any)?.type
|
||||
});
|
||||
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
targetUserId: remotePeerId,
|
||||
@@ -182,7 +184,8 @@ export class PeerConnectionManager {
|
||||
};
|
||||
|
||||
connection.onsignalingstatechange = () => {
|
||||
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState });
|
||||
this.logger.info('signalingstatechange', { remotePeerId,
|
||||
state: connection.signalingState });
|
||||
};
|
||||
|
||||
connection.onnegotiationneeded = () => {
|
||||
@@ -302,6 +305,7 @@ export class PeerConnectionManager {
|
||||
type: offer.type,
|
||||
sdpLength: offer.sdp?.length
|
||||
});
|
||||
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_OFFER,
|
||||
targetUserId: remotePeerId,
|
||||
@@ -370,11 +374,15 @@ export class PeerConnectionManager {
|
||||
const isPolite = localId > fromUserId;
|
||||
|
||||
if (!isPolite) {
|
||||
this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId });
|
||||
return; // Our offer takes priority – remote will answer it.
|
||||
this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId,
|
||||
localId });
|
||||
|
||||
return; // Our offer takes priority - remote will answer it.
|
||||
}
|
||||
|
||||
this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId });
|
||||
this.logger.info('Rolling back local offer (polite side)', { fromUserId,
|
||||
localId });
|
||||
|
||||
await peerData.connection.setLocalDescription({
|
||||
type: 'rollback'
|
||||
} as RTCSessionDescriptionInit);
|
||||
@@ -438,6 +446,7 @@ export class PeerConnectionManager {
|
||||
type: answer.type,
|
||||
sdpLength: answer.sdp?.length
|
||||
});
|
||||
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_ANSWER,
|
||||
targetUserId: fromUserId,
|
||||
@@ -482,7 +491,7 @@ export class PeerConnectionManager {
|
||||
|
||||
peerData.pendingIceCandidates = [];
|
||||
} else {
|
||||
this.logger.warn('Ignoring answer – wrong signaling state', {
|
||||
this.logger.warn('Ignoring answer - wrong signaling state', {
|
||||
state: peerData.connection.signalingState
|
||||
});
|
||||
}
|
||||
@@ -559,6 +568,7 @@ export class PeerConnectionManager {
|
||||
type: offer.type,
|
||||
sdpLength: offer.sdp?.length
|
||||
});
|
||||
|
||||
this.callbacks.sendRawMessage({
|
||||
type: SIGNALING_TYPE_OFFER,
|
||||
targetUserId: peerId,
|
||||
@@ -622,16 +632,19 @@ export class PeerConnectionManager {
|
||||
* @param message - The parsed JSON payload.
|
||||
*/
|
||||
private handlePeerMessage(peerId: string, message: any): void {
|
||||
this.logger.info('Received P2P message', { peerId, type: message?.type });
|
||||
this.logger.info('Received P2P message', { peerId,
|
||||
type: message?.type });
|
||||
|
||||
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
||||
this.sendCurrentStatesToPeer(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ping/pong latency measurement – handled internally, not forwarded
|
||||
// Ping/pong latency measurement - handled internally, not forwarded
|
||||
if (message.type === P2P_TYPE_PING) {
|
||||
this.sendToPeer(peerId, { type: P2P_TYPE_PONG, ts: message.ts } as any);
|
||||
this.sendToPeer(peerId, { type: P2P_TYPE_PONG,
|
||||
ts: message.ts } as any);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -642,14 +655,16 @@ export class PeerConnectionManager {
|
||||
const latencyMs = Math.round(performance.now() - sent);
|
||||
|
||||
this.peerLatencies.set(peerId, latencyMs);
|
||||
this.peerLatencyChanged$.next({ peerId, latencyMs });
|
||||
this.peerLatencyChanged$.next({ peerId,
|
||||
latencyMs });
|
||||
}
|
||||
|
||||
this.pendingPings.delete(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const enriched = { ...message, fromPeerId: peerId };
|
||||
const enriched = { ...message,
|
||||
fromPeerId: peerId };
|
||||
|
||||
this.messageReceived$.next(enriched);
|
||||
}
|
||||
@@ -682,7 +697,7 @@ export class PeerConnectionManager {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Peer not connected – cannot send', { peerId });
|
||||
this.logger.warn('Peer not connected - cannot send', { peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -706,7 +721,7 @@ export class PeerConnectionManager {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Peer not connected – cannot send buffered', { peerId });
|
||||
this.logger.warn('Peer not connected - cannot send buffered', { peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -748,7 +763,11 @@ export class PeerConnectionManager {
|
||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
||||
|
||||
this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any);
|
||||
this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
voiceState } as any);
|
||||
|
||||
this.sendToPeer(peerId, {
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
@@ -759,10 +778,11 @@ export class PeerConnectionManager {
|
||||
|
||||
private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
||||
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||
this.logger.warn('Cannot send states – channel not open', {
|
||||
this.logger.warn('Cannot send states - channel not open', {
|
||||
remotePeerId,
|
||||
state: channel.readyState
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -772,7 +792,11 @@ export class PeerConnectionManager {
|
||||
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
||||
|
||||
try {
|
||||
channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState }));
|
||||
channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
voiceState }));
|
||||
|
||||
channel.send(
|
||||
JSON.stringify({
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
@@ -781,7 +805,9 @@ export class PeerConnectionManager {
|
||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||
})
|
||||
);
|
||||
this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
|
||||
|
||||
this.logger.info('Sent initial states to channel', { remotePeerId,
|
||||
voiceState });
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to send initial states to channel', e);
|
||||
}
|
||||
@@ -794,7 +820,11 @@ export class PeerConnectionManager {
|
||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
||||
|
||||
this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any);
|
||||
this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
voiceState } as any);
|
||||
|
||||
this.broadcastMessage({
|
||||
type: P2P_TYPE_SCREEN_STATE,
|
||||
oderId,
|
||||
@@ -816,6 +846,7 @@ export class PeerConnectionManager {
|
||||
readyState: track.readyState,
|
||||
settings
|
||||
});
|
||||
|
||||
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
|
||||
|
||||
// Skip inactive video placeholder tracks
|
||||
@@ -825,6 +856,7 @@ export class PeerConnectionManager {
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -843,7 +875,8 @@ export class PeerConnectionManager {
|
||||
}
|
||||
|
||||
this.remotePeerStreams.set(remotePeerId, compositeStream);
|
||||
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
|
||||
this.remoteStream$.next({ peerId: remotePeerId,
|
||||
stream: compositeStream });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -879,6 +912,7 @@ export class PeerConnectionManager {
|
||||
|
||||
peerData.connection.close();
|
||||
});
|
||||
|
||||
this.activePeerConnections.clear();
|
||||
this.peerNegotiationQueue.clear();
|
||||
this.peerLatencies.clear();
|
||||
@@ -924,7 +958,8 @@ export class PeerConnectionManager {
|
||||
}
|
||||
|
||||
info.reconnectAttempts++;
|
||||
this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts });
|
||||
this.logger.info('P2P reconnect attempt', { peerId,
|
||||
attempt: info.reconnectAttempts });
|
||||
|
||||
if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
|
||||
this.logger.info('P2P reconnect max attempts reached', { peerId });
|
||||
@@ -934,7 +969,7 @@ export class PeerConnectionManager {
|
||||
}
|
||||
|
||||
if (!this.callbacks.isSignalingConnected()) {
|
||||
this.logger.info('Skipping P2P reconnect – no signaling connection', { peerId });
|
||||
this.logger.info('Skipping P2P reconnect - no signaling connection', { peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -996,6 +1031,7 @@ export class PeerConnectionManager {
|
||||
this.connectedPeersList = this.connectedPeersList.filter(
|
||||
(connectedId) => connectedId !== peerId
|
||||
);
|
||||
|
||||
this.connectedPeersChanged$.next(this.connectedPeersList);
|
||||
}
|
||||
|
||||
@@ -1047,7 +1083,8 @@ export class PeerConnectionManager {
|
||||
this.pendingPings.set(peerId, ts);
|
||||
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts }));
|
||||
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING,
|
||||
ts }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -80,11 +80,13 @@ export class ScreenShareManager {
|
||||
const sources = await (window as any).electronAPI.getSources();
|
||||
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
|
||||
const electronConstraints: any = {
|
||||
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }
|
||||
video: { mandatory: { chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: screenSource.id } }
|
||||
};
|
||||
|
||||
if (includeSystemAudio) {
|
||||
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } };
|
||||
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: screenSource.id } };
|
||||
} else {
|
||||
electronConstraints.audio = false;
|
||||
}
|
||||
@@ -109,7 +111,9 @@ export class ScreenShareManager {
|
||||
height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT },
|
||||
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }
|
||||
},
|
||||
audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false
|
||||
audio: includeSystemAudio ? { echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false } : false
|
||||
} as any;
|
||||
|
||||
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SignalingManager {
|
||||
private signalingReconnectTimer: ReturnType<typeof setTimeout> | 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>();
|
||||
|
||||
/** Fires whenever a raw signaling message arrives from the server. */
|
||||
@@ -73,14 +73,18 @@ export class SignalingManager {
|
||||
|
||||
this.signalingWebSocket.onerror = (error) => {
|
||||
this.logger.error('Signaling socket error', error);
|
||||
this.connectionStatus$.next({ connected: false, errorMessage: 'Connection to signaling server failed' });
|
||||
this.connectionStatus$.next({ connected: false,
|
||||
errorMessage: 'Connection to signaling server failed' });
|
||||
|
||||
observer.error(error);
|
||||
};
|
||||
|
||||
this.signalingWebSocket.onclose = () => {
|
||||
this.logger.info('Disconnected from signaling server');
|
||||
this.stopHeartbeat();
|
||||
this.connectionStatus$.next({ connected: false, errorMessage: 'Disconnected from signaling server' });
|
||||
this.connectionStatus$.next({ connected: false,
|
||||
errorMessage: 'Disconnected from signaling server' });
|
||||
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -118,7 +122,9 @@ export class SignalingManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() };
|
||||
const fullMessage: SignalingMessage = { ...message,
|
||||
from: localPeerId,
|
||||
timestamp: Date.now() };
|
||||
|
||||
this.signalingWebSocket!.send(JSON.stringify(fullMessage));
|
||||
}
|
||||
@@ -159,25 +165,31 @@ export class SignalingManager {
|
||||
const credentials = this.getLastIdentify();
|
||||
|
||||
if (credentials) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName });
|
||||
}
|
||||
|
||||
const memberIds = this.getMemberServerIds();
|
||||
|
||||
if (memberIds.size > 0) {
|
||||
memberIds.forEach((serverId) => {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId });
|
||||
});
|
||||
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||
serverId: lastJoined.serverId });
|
||||
}
|
||||
} else {
|
||||
const lastJoined = this.getLastJoinedServer();
|
||||
|
||||
if (lastJoined) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId });
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId: lastJoined.serverId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable id-length, max-statements-per-line */
|
||||
/**
|
||||
* VoiceLevelingManager — manages per-speaker automatic gain control
|
||||
* VoiceLevelingManager - manages per-speaker automatic gain control
|
||||
* pipelines for remote voice streams.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
@@ -13,9 +13,9 @@
|
||||
* ↓
|
||||
* MediaStreamSource (AudioContext)
|
||||
* ↓
|
||||
* AudioWorkletNode (VoiceLevelingProcessor — per-speaker AGC)
|
||||
* AudioWorkletNode (VoiceLevelingProcessor - per-speaker AGC)
|
||||
* ↓
|
||||
* GainNode (post fine-tuning — master volume knob)
|
||||
* GainNode (post fine-tuning - master volume knob)
|
||||
* ↓
|
||||
* MediaStreamDestination → leveled MediaStream
|
||||
*
|
||||
@@ -26,7 +26,7 @@
|
||||
* for browsers that don't support AudioWorklet or SharedArrayBuffer.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* DESIGN — mirrors the NoiseReductionManager pattern
|
||||
* DESIGN - mirrors the NoiseReductionManager pattern
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* • `enable(peerId, rawStream)` builds the pipeline and returns a
|
||||
@@ -37,7 +37,7 @@
|
||||
*
|
||||
* The calling component keeps a reference to the original raw stream
|
||||
* and swaps the Audio element's `srcObject` between the raw stream
|
||||
* and the leveled stream when the user toggles the feature — exactly
|
||||
* and the leveled stream when the user toggles the feature - exactly
|
||||
* like noise reduction does for the local mic.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
@@ -90,7 +90,7 @@ interface SpeakerPipeline {
|
||||
|
||||
/** AudioWorklet module path (served from public/). */
|
||||
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
|
||||
/** Processor name — must match `registerProcessor` in the worklet. */
|
||||
/** Processor name - must match `registerProcessor` in the worklet. */
|
||||
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────── */
|
||||
@@ -134,7 +134,9 @@ export class VoiceLevelingManager {
|
||||
* Only provided keys are updated; the rest stay unchanged.
|
||||
*/
|
||||
updateSettings(partial: Partial<VoiceLevelingSettings>): void {
|
||||
this._settings = { ...this._settings, ...partial };
|
||||
this._settings = { ...this._settings,
|
||||
...partial };
|
||||
|
||||
this.pipelines.forEach((p) => this._pushSettingsToPipeline(p));
|
||||
}
|
||||
|
||||
@@ -180,6 +182,7 @@ export class VoiceLevelingManager {
|
||||
peerId,
|
||||
fallback: pipeline.isFallback
|
||||
});
|
||||
|
||||
return pipeline.destination.stream;
|
||||
} catch (err) {
|
||||
this.logger.error('VoiceLeveling: pipeline build failed, returning raw stream', err);
|
||||
|
||||
@@ -46,9 +46,14 @@ export class WebRTCLogger {
|
||||
settings
|
||||
});
|
||||
|
||||
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind }));
|
||||
track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id, kind: track.kind }));
|
||||
track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id, kind: track.kind }));
|
||||
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id,
|
||||
kind: track.kind }));
|
||||
|
||||
track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id,
|
||||
kind: track.kind }));
|
||||
|
||||
track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id,
|
||||
kind: track.kind }));
|
||||
}
|
||||
|
||||
/** Log a MediaStream summary and attach diagnostics to every track. */
|
||||
@@ -65,8 +70,10 @@ export class WebRTCLogger {
|
||||
id: (stream as any).id,
|
||||
audioTrackCount: audioTracks.length,
|
||||
videoTrackCount: videoTracks.length,
|
||||
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind }))
|
||||
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id,
|
||||
kind: streamTrack.kind }))
|
||||
});
|
||||
|
||||
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
||||
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
|
||||
/** Data channel name used for P2P chat */
|
||||
export const DATA_CHANNEL_LABEL = 'chat';
|
||||
/** High-water mark (bytes) – pause sending when buffered amount exceeds this */
|
||||
/** High-water mark (bytes) - pause sending when buffered amount exceeds this */
|
||||
export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||
/** Low-water mark (bytes) – resume sending once buffered amount drops below this */
|
||||
/** Low-water mark (bytes) - resume sending once buffered amount drops below this */
|
||||
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||
|
||||
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="h-full flex flex-col bg-card">
|
||||
<!-- Header -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +20,10 @@
|
||||
[class.border-primary]="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
|
||||
</button>
|
||||
<button
|
||||
@@ -29,7 +35,10 @@
|
||||
[class.border-primary]="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
|
||||
</button>
|
||||
<button
|
||||
@@ -41,7 +50,10 @@
|
||||
[class.border-primary]="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
|
||||
</button>
|
||||
<button
|
||||
@@ -53,7 +65,10 @@
|
||||
[class.border-primary]="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
|
||||
</button>
|
||||
</div>
|
||||
@@ -67,7 +82,11 @@
|
||||
|
||||
<!-- Room Name -->
|
||||
<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
|
||||
type="text"
|
||||
id="room-name-input"
|
||||
@@ -78,7 +97,11 @@
|
||||
|
||||
<!-- Room Description -->
|
||||
<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
|
||||
id="room-description-input"
|
||||
[(ngModel)]="roomDescription"
|
||||
@@ -103,16 +126,26 @@
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon name="lucideLock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideUnlock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUnlock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Max Users -->
|
||||
<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
|
||||
type="number"
|
||||
id="max-users-input"
|
||||
@@ -128,7 +161,10 @@
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
@@ -140,7 +176,10 @@
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
@@ -151,13 +190,14 @@
|
||||
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
||||
|
||||
@if (membersFiltered().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">
|
||||
No other members online
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<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 items-center gap-1.5">
|
||||
<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"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -196,7 +239,10 @@
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -210,9 +256,7 @@
|
||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||
|
||||
@if (bannedUsers().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">
|
||||
No banned users
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
@@ -224,14 +268,10 @@
|
||||
{{ ban.displayName || 'Unknown User' }}
|
||||
</p>
|
||||
@if (ban.reason) {
|
||||
<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) {
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Expires: {{ formatExpiry(ban.expiresAt) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
@@ -241,7 +281,10 @@
|
||||
(click)="unbanUser(ban)"
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
@@ -363,7 +406,10 @@
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -35,7 +39,13 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
@@ -181,7 +191,8 @@ export class AdminPanelComponent {
|
||||
formatExpiry(timestamp: number): string {
|
||||
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
|
||||
@@ -194,7 +205,9 @@ export class AdminPanelComponent {
|
||||
|
||||
/** Change a member's role and broadcast the update to all peers. */
|
||||
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({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
@@ -16,7 +23,11 @@
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
@@ -25,7 +36,11 @@
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="serverId"
|
||||
id="login-server"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html'
|
||||
})
|
||||
@@ -43,7 +51,9 @@ export class LoginComponent {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
|
||||
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
|
||||
this.auth.login({ username: this.username.trim(),
|
||||
password: this.password,
|
||||
serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
@@ -16,7 +23,11 @@
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="displayName"
|
||||
type="text"
|
||||
@@ -25,7 +36,11 @@
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
@@ -34,7 +49,11 @@
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="serverId"
|
||||
id="register-server"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html'
|
||||
})
|
||||
@@ -44,7 +52,10 @@ export class RegisterComponent {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
|
||||
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
|
||||
this.auth.register({ username: this.username.trim(),
|
||||
password: this.password,
|
||||
displayName: this.displayName.trim(),
|
||||
serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
@@ -2,16 +2,33 @@
|
||||
<div class="flex-1"></div>
|
||||
@if (user()) {
|
||||
<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>
|
||||
</div>
|
||||
} @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">
|
||||
<ng-icon name="lucideLogIn" class="w-4 h-4" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('login')"
|
||||
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Login
|
||||
</button>
|
||||
<button 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" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('register')"
|
||||
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Register
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -4,14 +4,22 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideUser,
|
||||
lucideLogIn,
|
||||
lucideUserPlus })
|
||||
],
|
||||
templateUrl: './user-bar.component.html'
|
||||
})
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-len, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
AfterViewChecked,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
ChangeDetectorRef
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -21,7 +33,11 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors';
|
||||
import {
|
||||
selectAllMessages,
|
||||
selectMessagesLoading,
|
||||
selectMessagesSyncing
|
||||
} from '../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
@@ -36,12 +52,30 @@ import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import { ChatMarkdownService } from './services/chat-markdown.service';
|
||||
|
||||
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
||||
const COMMON_EMOJIS = [
|
||||
'👍',
|
||||
'❤️',
|
||||
'😂',
|
||||
'😮',
|
||||
'😢',
|
||||
'🎉',
|
||||
'🔥',
|
||||
'👀'
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-messages',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
TypingIndicatorComponent,
|
||||
RemarkModule,
|
||||
MermaidComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSend,
|
||||
@@ -105,7 +139,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
);
|
||||
});
|
||||
|
||||
/** Paginated view — only the most recent `displayLimit` messages */
|
||||
/** Paginated view - only the most recent `displayLimit` messages */
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
@@ -391,7 +425,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.scrollIntoView({ behavior: 'smooth',
|
||||
block: 'center' });
|
||||
|
||||
el.classList.add('bg-primary/10');
|
||||
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
|
||||
}
|
||||
@@ -406,7 +442,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Add a reaction emoji to a message. */
|
||||
addReaction(messageId: string, emoji: string): void {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||
emoji }));
|
||||
|
||||
this.showEmojiPicker.set(null);
|
||||
}
|
||||
|
||||
@@ -423,9 +461,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
);
|
||||
|
||||
if (hasReacted) {
|
||||
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
|
||||
this.store.dispatch(MessagesActions.removeReaction({ messageId,
|
||||
emoji }));
|
||||
} else {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||
emoji }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +480,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
message.reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||
const existing = groups.get(reaction.emoji) || { count: 0,
|
||||
hasCurrentUser: false };
|
||||
|
||||
groups.set(reaction.emoji, {
|
||||
count: existing.count + 1,
|
||||
@@ -458,7 +499,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef);
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' });
|
||||
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||
@@ -470,7 +512,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
} else if (dayDiff < 7) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
|
||||
return date.toLocaleDateString([], { month: 'short',
|
||||
day: 'numeric' }) + ' ' + time;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +556,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.initialScrollObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(snap);
|
||||
});
|
||||
|
||||
this.initialScrollObserver.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
@@ -550,7 +594,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
el.scrollTo({ top: el.scrollHeight,
|
||||
behavior: 'smooth' });
|
||||
} catch {
|
||||
// Fallback if smooth not supported
|
||||
el.scrollTop = el.scrollHeight;
|
||||
@@ -591,7 +636,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
|
||||
// Infinite scroll upwards — load older messages when near the top
|
||||
// Infinite scroll upwards - load older messages when near the top
|
||||
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
}
|
||||
@@ -640,7 +685,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
||||
return { start: el?.selectionStart ?? this.messageContent.length,
|
||||
end: el?.selectionEnd ?? this.messageContent.length };
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
@@ -772,7 +818,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||
formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const units = [
|
||||
'B',
|
||||
'KB',
|
||||
'MB',
|
||||
'GB'
|
||||
];
|
||||
|
||||
let size = bytes;
|
||||
let i = 0;
|
||||
@@ -787,7 +838,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
if (!bps || bps <= 0)
|
||||
return '0 B/s';
|
||||
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
const units = [
|
||||
'B/s',
|
||||
'KB/s',
|
||||
'MB/s',
|
||||
'GB/s'
|
||||
];
|
||||
|
||||
let speed = bps;
|
||||
let i = 0;
|
||||
@@ -855,7 +911,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
|
||||
this.imageContextMenu.set({ x: event.clientX,
|
||||
y: event.clientY,
|
||||
attachment: att });
|
||||
}
|
||||
|
||||
/** Close the image context menu. */
|
||||
@@ -876,9 +934,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
// Convert to PNG for clipboard compatibility
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob })
|
||||
]);
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
||||
} catch (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export class ChatMarkdownService {
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text: newText,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
|
||||
@@ -34,7 +36,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
|
||||
@@ -49,7 +53,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -62,7 +68,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -74,7 +82,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyLink(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -87,7 +97,9 @@ export class ChatMarkdownService {
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
applyImage(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -99,7 +111,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -110,7 +124,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${hr}${after}`;
|
||||
const cursor = before.length + hr.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
appendImageMarkdown(content: string): string {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
|
||||
import { Component, inject, signal, DestroyRef } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
DestroyRef
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { merge, interval, filter, map, tap } from 'rxjs';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
filter,
|
||||
map,
|
||||
tap
|
||||
} from 'rxjs';
|
||||
|
||||
const TYPING_TTL = 3_000;
|
||||
const PURGE_INTERVAL = 1_000;
|
||||
|
||||
@@ -1,161 +1,202 @@
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground">Members</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
||||
@if (voiceUsers().length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@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="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
{{ v.displayName }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground">Members</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
||||
@if (voiceUsers().length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@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="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
{{ v.displayName }}
|
||||
</span>
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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"
|
||||
<!-- 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"
|
||||
/>
|
||||
</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>
|
||||
<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"
|
||||
@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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -29,7 +34,13 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
<!-- No Room Selected -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<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>
|
||||
<p class="text-sm">Select or create a room to start chatting</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
[class.text-muted-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>
|
||||
</button>
|
||||
<button
|
||||
@@ -25,11 +28,12 @@
|
||||
[class.text-muted-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 class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{
|
||||
onlineUsers().length
|
||||
}}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,16 +44,17 @@
|
||||
<!-- Text Channels -->
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
||||
Text Channels
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button
|
||||
(click)="createChannel('text')"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
@@ -89,16 +94,17 @@
|
||||
<!-- Voice Channels -->
|
||||
<div class="p-3 pt-0">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
||||
Voice Channels
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button
|
||||
(click)="createChannel('voice')"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
@@ -116,7 +122,10 @@
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<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) {
|
||||
<input
|
||||
#renameInput
|
||||
@@ -155,17 +164,13 @@
|
||||
: 'ring-2 ring-green-500/40'
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{
|
||||
u.displayName
|
||||
}}</span>
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
<!-- Ping latency indicator -->
|
||||
@if (u.id !== currentUser()?.id) {
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
[class]="getPingColorClass(u)"
|
||||
[title]="
|
||||
getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'
|
||||
"
|
||||
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
|
||||
></span>
|
||||
}
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
@@ -177,7 +182,10 @@
|
||||
</button>
|
||||
}
|
||||
@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>
|
||||
}
|
||||
@@ -196,9 +204,7 @@
|
||||
<!-- Current User (You) -->
|
||||
@if (currentUser()) {
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
|
||||
You
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
@@ -206,27 +212,26 @@
|
||||
[avatarUrl]="currentUser()?.avatarUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
|
||||
></span>
|
||||
<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 class="flex-1 min-w-0">
|
||||
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (currentUser()?.voiceState?.isConnected) {
|
||||
<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
|
||||
</p>
|
||||
}
|
||||
@if (
|
||||
currentUser()?.screenShareState?.isSharing ||
|
||||
(currentUser()?.id && isUserSharing(currentUser()!.id))
|
||||
) {
|
||||
<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"
|
||||
>
|
||||
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
|
||||
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
|
||||
<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">
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
</span>
|
||||
}
|
||||
@@ -239,9 +244,7 @@
|
||||
<!-- Other Online Users -->
|
||||
@if (onlineUsersFiltered().length > 0) {
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
|
||||
Online — {{ onlineUsersFiltered().length }}
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineUsersFiltered().length }}</h4>
|
||||
<div class="space-y-1">
|
||||
@for (user of onlineUsersFiltered(); track user.id) {
|
||||
<div
|
||||
@@ -254,34 +257,26 @@
|
||||
[avatarUrl]="user.avatarUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
|
||||
></span>
|
||||
<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 class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
||||
@if (user.role === 'host') {
|
||||
<span
|
||||
class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium"
|
||||
>Owner</span
|
||||
>
|
||||
<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') {
|
||||
<span
|
||||
class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium"
|
||||
>Admin</span
|
||||
>
|
||||
<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') {
|
||||
<span
|
||||
class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium"
|
||||
>Mod</span
|
||||
>
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<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
|
||||
</p>
|
||||
}
|
||||
@@ -290,7 +285,10 @@
|
||||
(click)="viewStream(user.id); $event.stopPropagation()"
|
||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
|
||||
>
|
||||
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@@ -327,42 +325,81 @@
|
||||
(closed)="closeChannelMenu()"
|
||||
[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()) {
|
||||
<div class="context-menu-divider"></div>
|
||||
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
|
||||
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
|
||||
<button
|
||||
(click)="startRename()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Rename Channel
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteChannel()"
|
||||
class="context-menu-item-danger"
|
||||
>
|
||||
Delete Channel
|
||||
</button>
|
||||
}
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
<!-- User context menu (kick / role management) -->
|
||||
@if (showUserMenu()) {
|
||||
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
|
||||
<app-context-menu
|
||||
[x]="userMenuX()"
|
||||
[y]="userMenuY()"
|
||||
(closed)="closeUserMenu()"
|
||||
>
|
||||
@if (isAdmin()) {
|
||||
@if (contextMenuUser()?.role === 'member') {
|
||||
<button (click)="changeUserRole('moderator')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('moderator')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Moderator
|
||||
</button>
|
||||
<button (click)="changeUserRole('admin')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('admin')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'moderator') {
|
||||
<button (click)="changeUserRole('admin')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('admin')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
<button (click)="changeUserRole('member')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('member')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'admin') {
|
||||
<button (click)="changeUserRole('member')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('member')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
<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 {
|
||||
<div class="context-menu-empty">No actions available</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -32,8 +36,17 @@ import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
import { Channel, User } from '../../../core/models';
|
||||
import {
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
} from '../../../shared';
|
||||
import {
|
||||
Channel,
|
||||
ChatEvent,
|
||||
Room,
|
||||
User
|
||||
} from '../../../core/models';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type TabView = 'channels' | 'users';
|
||||
@@ -41,15 +54,7 @@ type TabView = 'channels' | 'users';
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
VoiceControlsComponent,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMessageSquare,
|
||||
@@ -110,9 +115,7 @@ export class RoomsSidePanelComponent {
|
||||
const currentId = current?.id;
|
||||
const currentOderId = current?.oderId;
|
||||
|
||||
return this.onlineUsers().filter(
|
||||
(user) => user.id !== currentId && user.oderId !== currentOderId
|
||||
);
|
||||
return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
|
||||
}
|
||||
|
||||
/** Check whether the current user has permission to manage channels. */
|
||||
@@ -218,12 +221,14 @@ export class RoomsSidePanelComponent {
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (peers.length === 0) {
|
||||
// No connected peers — sync will time out
|
||||
// No connected peers - sync will time out
|
||||
}
|
||||
|
||||
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
||||
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
||||
this.webrtc.sendToPeer(pid, inventoryRequest);
|
||||
} catch (_error) {
|
||||
// Failed to send inventory request to this peer
|
||||
}
|
||||
@@ -327,6 +332,9 @@ export class RoomsSidePanelComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||
@@ -334,7 +342,7 @@ export class RoomsSidePanelComponent {
|
||||
// clear it so the user can join.
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
if (!this.webrtc.isVoiceConnected()) {
|
||||
// Stale state – clear it so the user can proceed
|
||||
// Stale state - clear it so the user can proceed
|
||||
if (current.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
@@ -356,67 +364,76 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
|
||||
// If switching channels within the same server, just update the room
|
||||
const isSwitchingChannels =
|
||||
current?.voiceState?.isConnected &&
|
||||
current.voiceState.serverId === room?.id &&
|
||||
current.voiceState.roomId !== roomId;
|
||||
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
||||
// Enable microphone and broadcast voice-state
|
||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||
|
||||
enableVoicePromise
|
||||
.then(() => {
|
||||
if (current?.id && room) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Update voice session for floating controls
|
||||
if (room) {
|
||||
// Find label from channel list
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId: roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch((_error) => {
|
||||
// Failed to join voice room
|
||||
});
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
this.updateVoiceStateStore(roomId, room, current);
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
this.broadcastVoiceConnected(roomId, room, current);
|
||||
this.startVoiceSession(roomId, room);
|
||||
}
|
||||
|
||||
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
||||
if (!current?.id)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||
this.webrtc.startVoiceHeartbeat(roomId, room.id);
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startVoiceSession(roomId: string, room: Room): void {
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
|
||||
/** Leave a voice channel and broadcast the disconnect state. */
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
@@ -470,12 +487,8 @@ export class RoomsSidePanelComponent {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return users.filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id
|
||||
).length;
|
||||
return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id)
|
||||
.length;
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||
@@ -500,9 +513,7 @@ export class RoomsSidePanelComponent {
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
|
||||
const user = this.onlineUsers().find(
|
||||
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
|
||||
);
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
@@ -518,10 +529,7 @@ export class RoomsSidePanelComponent {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return this.onlineUsers().filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id
|
||||
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -530,11 +538,7 @@ export class RoomsSidePanelComponent {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!(
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
me.voiceState?.serverId === room?.id
|
||||
);
|
||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||
}
|
||||
|
||||
/** Check whether voice is enabled by the current room's permissions. */
|
||||
@@ -558,8 +562,8 @@ export class RoomsSidePanelComponent {
|
||||
/**
|
||||
* Return a Tailwind `bg-*` class representing the latency quality.
|
||||
* - green : < 100 ms
|
||||
* - yellow : 100–199 ms
|
||||
* - orange : 200–349 ms
|
||||
* - yellow : 100-199 ms
|
||||
* - orange : 200-349 ms
|
||||
* - red : >= 350 ms
|
||||
* - gray : no data yet
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +55,10 @@
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Create New Server
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +71,10 @@
|
||||
</div>
|
||||
} @else if (searchResults().length === 0) {
|
||||
<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-sm">Try a different search or create your own</p>
|
||||
</div>
|
||||
@@ -84,9 +93,15 @@
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
@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 {
|
||||
<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>
|
||||
@if (server.description) {
|
||||
@@ -101,13 +116,14 @@
|
||||
}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
Hosted by {{ server.hostName }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-muted-foreground">Hosted by {{ server.hostName }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -145,7 +161,11 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
@@ -156,7 +176,11 @@
|
||||
</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
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
@@ -167,7 +191,11 @@
|
||||
</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
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
@@ -184,12 +212,20 @@
|
||||
id="private"
|
||||
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>
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<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
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
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 {
|
||||
lucideSearch,
|
||||
@@ -147,16 +156,20 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
this.joinServer({
|
||||
this.joinServer(this.toServerInfo(room));
|
||||
}
|
||||
|
||||
private toServerInfo(room: Room): ServerInfo {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
maxUsers: room.maxUsers ?? 50,
|
||||
isPrivate: !!room.password,
|
||||
createdAt: room.createdAt
|
||||
} as any);
|
||||
};
|
||||
}
|
||||
|
||||
private resetCreateForm(): void {
|
||||
|
||||
@@ -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">
|
||||
<!-- Create 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"
|
||||
title="Create Server"
|
||||
(click)="createServer()"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<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) {
|
||||
<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"
|
||||
[title]="room.name"
|
||||
(click)="joinSavedRoom(room)"
|
||||
(contextmenu)="openContextMenu($event, room)"
|
||||
>
|
||||
@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 {
|
||||
<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>
|
||||
@@ -31,11 +40,28 @@
|
||||
|
||||
<!-- Context menu -->
|
||||
@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()) {
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -48,6 +74,8 @@
|
||||
(cancelled)="cancelForget()"
|
||||
[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>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -16,7 +20,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent, NgOptimizedImage],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
@@ -92,14 +96,16 @@ export class ServersRailComponent {
|
||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||
} else {
|
||||
// First time joining this server
|
||||
this.store.dispatch(RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown'
|
||||
}
|
||||
}));
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown'
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +114,7 @@ export class ServersRailComponent {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
// 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.showMenu.set(true);
|
||||
}
|
||||
@@ -161,5 +167,4 @@ export class ServersRailComponent {
|
||||
cancelForget(): void {
|
||||
this.showConfirm.set(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm"
|
||||
>
|
||||
<div 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() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -18,19 +16,21 @@
|
||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||
}
|
||||
@if (ban.expiresAt) {
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Expires: {{ formatExpiry(ban.expiresAt) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
</div>
|
||||
@if (isAdmin()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="unbanUser(ban)"
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
@@ -38,7 +38,5 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* 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 { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -40,7 +44,8 @@ export class BansSettingsComponent {
|
||||
return (
|
||||
date.toLocaleDateString() +
|
||||
' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,21 @@
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<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 items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ user.displayName }}
|
||||
</p>
|
||||
@if (user.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded"
|
||||
>Owner</span
|
||||
>
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded"
|
||||
>Admin</span
|
||||
>
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded"
|
||||
>Mod</span
|
||||
>
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,14 +39,20 @@
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
(click)="banMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -58,7 +61,5 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -15,7 +19,12 @@ import { UserAvatarComponent } from '../../../../shared';
|
||||
@Component({
|
||||
selector: 'app-members-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideUserX,
|
||||
@@ -43,7 +52,9 @@ export class MembersSettingsComponent {
|
||||
}
|
||||
|
||||
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({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -11,14 +14,16 @@
|
||||
[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"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 List -->
|
||||
<div class="space-y-2 mb-3">
|
||||
@@ -41,10 +46,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
|
||||
@if (server.isActive) {
|
||||
<span
|
||||
class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full"
|
||||
>Active</span
|
||||
>
|
||||
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@@ -105,7 +107,10 @@
|
||||
[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"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
@@ -117,7 +122,10 @@
|
||||
<!-- Connection Settings -->
|
||||
<section>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -18,7 +22,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
@Component({
|
||||
selector: 'app-network-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideGlobe,
|
||||
@@ -65,6 +73,7 @@ export class NetworkSettingsComponent {
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, '')
|
||||
});
|
||||
|
||||
this.newServerName = '';
|
||||
this.newServerUrl = '';
|
||||
const servers = this.servers();
|
||||
@@ -108,6 +117,7 @@ export class NetworkSettingsComponent {
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
@if (server()) {
|
||||
<div class="space-y-4 max-w-xl">
|
||||
@if (!isAdmin()) {
|
||||
<p class="text-xs text-muted-foreground mb-1">
|
||||
You are viewing this server's permissions. Only the server owner can make changes.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
|
||||
}
|
||||
<div class="space-y-2.5">
|
||||
<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.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' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -12,7 +17,11 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
@Component({
|
||||
selector: 'app-permissions-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck
|
||||
@@ -75,6 +84,7 @@ export class PermissionsSettingsComponent {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.showSaveSuccess('permissions');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
|
||||
@if (!isAdmin()) {
|
||||
<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
|
||||
changes.
|
||||
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
|
||||
</p>
|
||||
}
|
||||
<div class="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
[(ngModel)]="roomName"
|
||||
@@ -22,7 +25,11 @@
|
||||
/>
|
||||
</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
|
||||
[(ngModel)]="roomDescription"
|
||||
[readOnly]="!isAdmin()"
|
||||
@@ -49,9 +56,15 @@
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon name="lucideLock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideUnlock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUnlock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +78,10 @@
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@@ -90,7 +106,10 @@
|
||||
[class.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' }}
|
||||
</button>
|
||||
|
||||
@@ -102,7 +121,10 @@
|
||||
type="button"
|
||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
@@ -123,7 +145,5 @@
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
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 { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
@@ -14,7 +25,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
@@ -78,6 +94,7 @@ export class ServerSettingsComponent {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.showSaveSuccess('server');
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,7 @@
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<!-- Global section -->
|
||||
<p
|
||||
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
|
||||
>
|
||||
General
|
||||
</p>
|
||||
<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) {
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
@@ -51,7 +47,10 @@
|
||||
[class.text-foreground]="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 }}
|
||||
</button>
|
||||
}
|
||||
@@ -59,11 +58,7 @@
|
||||
<!-- Server section -->
|
||||
@if (savedRooms().length > 0) {
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<p
|
||||
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
|
||||
>
|
||||
Server
|
||||
</p>
|
||||
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
|
||||
|
||||
<!-- Server selector -->
|
||||
<div class="px-3 pb-2">
|
||||
@@ -91,7 +86,10 @@
|
||||
[class.text-foreground]="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 }}
|
||||
</button>
|
||||
}
|
||||
@@ -104,9 +102,7 @@
|
||||
<!-- Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"
|
||||
>
|
||||
<div 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">
|
||||
@switch (activePage()) {
|
||||
@case ('network') {
|
||||
@@ -134,7 +130,10 @@
|
||||
type="button"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ import {
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectCurrentUser
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { Room } from '../../../core/models';
|
||||
|
||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||
@@ -80,14 +78,25 @@ export class SettingsModalComponent {
|
||||
|
||||
// --- Side-nav items ---
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }
|
||||
{ id: 'network',
|
||||
label: 'Network',
|
||||
icon: 'lucideGlobe' }, { id: 'voice',
|
||||
label: 'Voice & Audio',
|
||||
icon: 'lucideAudioLines' }
|
||||
];
|
||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
||||
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
||||
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
||||
{ id: 'server',
|
||||
label: 'Server',
|
||||
icon: 'lucideSettings' },
|
||||
{ id: 'members',
|
||||
label: 'Members',
|
||||
icon: 'lucideUsers' },
|
||||
{ id: 'bans',
|
||||
label: 'Bans',
|
||||
icon: 'lucideBan' },
|
||||
{ id: 'permissions',
|
||||
label: 'Permissions',
|
||||
icon: 'lucideShield' }
|
||||
];
|
||||
|
||||
// ===== SERVER SELECTOR =====
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
<!-- Devices -->
|
||||
<section>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
(change)="onInputDeviceChange($event)"
|
||||
id="input-device-select"
|
||||
@@ -24,7 +31,11 @@
|
||||
</select>
|
||||
</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
|
||||
(change)="onOutputDeviceChange($event)"
|
||||
id="output-device-select"
|
||||
@@ -46,12 +57,18 @@
|
||||
<!-- Volume -->
|
||||
<section>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<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() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -65,7 +82,10 @@
|
||||
/>
|
||||
</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() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -79,7 +99,10 @@
|
||||
/>
|
||||
</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' }}%
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -102,9 +125,7 @@
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted-foreground/60 mt-1">
|
||||
Controls join, leave & notification sounds
|
||||
</p>
|
||||
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave & notification sounds</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -112,24 +133,49 @@
|
||||
<!-- Quality & Processing -->
|
||||
<section>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<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
|
||||
(change)="onLatencyProfileChange($event)"
|
||||
id="latency-profile-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option>
|
||||
<option value="balanced" [selected]="latencyProfile() === 'balanced'">Balanced</option>
|
||||
<option value="high" [selected]="latencyProfile() === 'high'">High (quality)</option>
|
||||
<option
|
||||
value="low"
|
||||
[selected]="latencyProfile() === 'low'"
|
||||
>
|
||||
Low (fast)
|
||||
</option>
|
||||
<option
|
||||
value="balanced"
|
||||
[selected]="latencyProfile() === 'balanced'"
|
||||
>
|
||||
Balanced
|
||||
</option>
|
||||
<option
|
||||
value="high"
|
||||
[selected]="latencyProfile() === 'high'"
|
||||
>
|
||||
High (quality)
|
||||
</option>
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@@ -187,7 +233,10 @@
|
||||
<!-- Voice Leveling -->
|
||||
<section>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@@ -212,12 +261,15 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Advanced controls — visible only when enabled -->
|
||||
<!-- Advanced controls - visible only when enabled -->
|
||||
@if (voiceLeveling.enabled()) {
|
||||
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
|
||||
<!-- Target Loudness -->
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -238,19 +290,32 @@
|
||||
|
||||
<!-- AGC Strength -->
|
||||
<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
|
||||
(change)="onStrengthChange($event)"
|
||||
id="agc-strength-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="low" [selected]="voiceLeveling.strength() === 'low'">
|
||||
<option
|
||||
value="low"
|
||||
[selected]="voiceLeveling.strength() === 'low'"
|
||||
>
|
||||
Low (gentle)
|
||||
</option>
|
||||
<option value="medium" [selected]="voiceLeveling.strength() === 'medium'">
|
||||
<option
|
||||
value="medium"
|
||||
[selected]="voiceLeveling.strength() === 'medium'"
|
||||
>
|
||||
Medium
|
||||
</option>
|
||||
<option value="high" [selected]="voiceLeveling.strength() === 'high'">
|
||||
<option
|
||||
value="high"
|
||||
[selected]="voiceLeveling.strength() === 'high'"
|
||||
>
|
||||
High (aggressive)
|
||||
</option>
|
||||
</select>
|
||||
@@ -258,7 +323,10 @@
|
||||
|
||||
<!-- Max Gain Boost -->
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@@ -279,7 +347,10 @@
|
||||
|
||||
<!-- Response Speed -->
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
@@ -287,11 +358,22 @@
|
||||
id="response-speed-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="slow" [selected]="voiceLeveling.speed() === 'slow'">
|
||||
<option
|
||||
value="slow"
|
||||
[selected]="voiceLeveling.speed() === 'slow'"
|
||||
>
|
||||
Slow (natural)
|
||||
</option>
|
||||
<option value="medium" [selected]="voiceLeveling.speed() === 'medium'">Medium</option>
|
||||
<option value="fast" [selected]="voiceLeveling.speed() === 'fast'">
|
||||
<option
|
||||
value="medium"
|
||||
[selected]="voiceLeveling.speed() === 'medium'"
|
||||
>
|
||||
Medium
|
||||
</option>
|
||||
<option
|
||||
value="fast"
|
||||
[selected]="voiceLeveling.speed() === 'fast'"
|
||||
>
|
||||
Fast (aggressive)
|
||||
</option>
|
||||
</select>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
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 { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
||||
import {
|
||||
NotificationAudioService,
|
||||
AppSound
|
||||
} from '../../../../core/services/notification-audio.service';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -21,7 +27,11 @@ interface AudioDevice {
|
||||
@Component({
|
||||
selector: 'app-voice-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
@@ -63,12 +73,15 @@ export class VoiceSettingsComponent {
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +21,10 @@
|
||||
<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 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>
|
||||
</div>
|
||||
<button
|
||||
@@ -23,14 +32,18 @@
|
||||
[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"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Add multiple server directories to search for rooms across different networks. The active
|
||||
server will be used for creating and registering new rooms.
|
||||
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
|
||||
rooms.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
@@ -58,9 +71,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
|
||||
@if (server.isActive) {
|
||||
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full"
|
||||
>Active</span
|
||||
>
|
||||
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@@ -123,7 +134,10 @@
|
||||
[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"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
@@ -135,7 +149,10 @@
|
||||
<!-- Connection Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -143,9 +160,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Auto-reconnect</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Automatically reconnect when connection is lost
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
@@ -163,9 +178,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Search all servers</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Search across all configured server directories
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
@@ -185,7 +198,10 @@
|
||||
<!-- Voice Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -195,9 +211,7 @@
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Notification volume</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Volume for join, leave, and notification sounds
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
|
||||
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
||||
@@ -226,9 +240,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Noise reduction</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Use RNNoise to suppress background noise from your microphone
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* 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 { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -25,7 +30,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideServer,
|
||||
@@ -142,6 +151,7 @@ export class SettingsComponent implements OnInit {
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
@@ -190,8 +200,10 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_VOICE_SETTINGS,
|
||||
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction })
|
||||
JSON.stringify({ ...existing,
|
||||
noiseReduction: this.noiseReduction })
|
||||
);
|
||||
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
<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"
|
||||
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()) {
|
||||
<button type="button" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
<button
|
||||
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>
|
||||
}
|
||||
@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>
|
||||
@if (roomDescription()) {
|
||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||
{{ roomDescription() }}
|
||||
</span>
|
||||
}
|
||||
<button type="button" (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
|
||||
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMenu()"
|
||||
class="ml-2 p-2 hover:bg-secondary rounded"
|
||||
title="Menu"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMenu"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
@if (showMenu()) {
|
||||
@@ -48,7 +70,10 @@
|
||||
</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()) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,14 +85,38 @@
|
||||
</button>
|
||||
}
|
||||
@if (isElectron()) {
|
||||
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
|
||||
<ng-icon name="lucideMinus" class="w-4 h-4" />
|
||||
<button
|
||||
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 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
|
||||
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 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
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
@@ -82,6 +131,6 @@
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close menu overlay"
|
||||
style="-webkit-app-region: no-drag;"
|
||||
style="-webkit-app-region: no-drag"
|
||||
></div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
/* 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 { Store } from '@ngrx/store';
|
||||
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 { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
@@ -17,7 +29,14 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
selector: 'app-title-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMinus,
|
||||
lucideSquare,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
/**
|
||||
@@ -102,7 +121,7 @@ export class TitleBarComponent {
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
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.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
|
||||
@@ -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"
|
||||
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) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
@@ -40,7 +43,10 @@
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
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
|
||||
@@ -49,7 +55,10 @@
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
<ng-icon name="lucideHeadphones" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -58,7 +67,10 @@
|
||||
[class]="getCompactScreenShareClass()"
|
||||
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
|
||||
@@ -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"
|
||||
title="Disconnect"
|
||||
>
|
||||
<ng-icon name="lucidePhoneOff" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/* 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 { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -163,7 +170,11 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
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 }
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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="flex items-center justify-between">
|
||||
<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()) {
|
||||
<span class="text-sm font-medium">
|
||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
||||
</span>
|
||||
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
|
||||
} @else {
|
||||
<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"
|
||||
>
|
||||
@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 {
|
||||
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
|
||||
<ng-icon
|
||||
name="lucideMaximize"
|
||||
class="w-4 h-4 text-white"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
@if (isLocalShare()) {
|
||||
@@ -58,7 +65,10 @@
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
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>
|
||||
} @else {
|
||||
<button
|
||||
@@ -67,7 +77,10 @@
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
@@ -78,7 +91,10 @@
|
||||
@if (!hasStream()) {
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* 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 { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -235,6 +243,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
.catch(() => {});
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ export class VoicePlaybackService {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
});
|
||||
|
||||
this.remoteAudioElements.clear();
|
||||
this.rawRemoteStreams.clear();
|
||||
this.pendingRemoteStreams.clear();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div class="bg-card border-border p-4">
|
||||
<!-- Connection Error Banner -->
|
||||
@if (showConnectionError()) {
|
||||
<div
|
||||
class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<div 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="text-xs text-destructive">{{
|
||||
connectionErrorMessage() || 'Connection error'
|
||||
}}</span>
|
||||
<button type="button" (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
|
||||
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
(click)="retryConnection()"
|
||||
class="ml-auto text-xs text-destructive hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
<!-- User Info -->
|
||||
<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">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
@@ -31,8 +34,15 @@
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors">
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleSettings()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -40,25 +50,52 @@
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideMic" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMonitorOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideMonitor" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
@@ -68,7 +105,10 @@
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="lucidePhoneOff" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,11 @@ interface AudioDevice {
|
||||
@Component({
|
||||
selector: 'app-voice-controls',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
@@ -164,12 +168,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch (_error) {}
|
||||
}
|
||||
@@ -502,7 +509,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.webrtcService.setLatencyProfile(this.latencyProfile());
|
||||
this.applyOutputDevice();
|
||||
// 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());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Component, input, output, HostListener } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
HostListener
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* Reusable confirmation dialog modal.
|
||||
|
||||
@@ -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.
|
||||
@@ -13,9 +18,9 @@ import { Component, input, output, HostListener } from '@angular/core';
|
||||
* ```
|
||||
*
|
||||
* Built-in item classes are available via the host styles:
|
||||
* - `.context-menu-item` — normal item
|
||||
* - `.context-menu-item-danger` — destructive (red) item
|
||||
* - `.context-menu-divider` — horizontal separator
|
||||
* - `.context-menu-item` - normal item
|
||||
* - `.context-menu-item-danger` - destructive (red) item
|
||||
* - `.context-menu-divider` - horizontal separator
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Component, input } from '@angular/core';
|
||||
styles: [':host { display: contents; }']
|
||||
})
|
||||
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>();
|
||||
/** Optional avatar image URL. */
|
||||
avatarUrl = input<string | undefined | null>();
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Root state definition and barrel exports for the NgRx store.
|
||||
*
|
||||
* Three feature slices:
|
||||
* - **messages** – chat messages, reactions, sync state
|
||||
* - **users** – online users, bans, roles, voice state
|
||||
* - **rooms** – servers / rooms, channels, search results
|
||||
* - **messages** - chat messages, reactions, sync state
|
||||
* - **users** - online users, bans, roles, voice state
|
||||
* - **rooms** - servers / rooms, channels, search results
|
||||
*/
|
||||
import { isDevMode } from '@angular/core';
|
||||
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
* handlers, and `dispatchIncomingMessage()` is the single entry point
|
||||
* 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 { Action } from '@ngrx/store';
|
||||
import { Message } from '../../core/models';
|
||||
@@ -264,6 +269,7 @@ function handleMessageEdited(
|
||||
content: event.content,
|
||||
editedAt: event.editedAt
|
||||
});
|
||||
|
||||
return of(
|
||||
MessagesActions.editMessageSuccess({
|
||||
messageId: event.messageId,
|
||||
|
||||
@@ -10,9 +10,19 @@
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
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 { of, from, timer, Subject, EMPTY } from 'rxjs';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
timer,
|
||||
Subject,
|
||||
EMPTY
|
||||
} from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
mergeMap,
|
||||
@@ -78,6 +88,7 @@ export class MessagesSyncEffects {
|
||||
count,
|
||||
lastUpdated
|
||||
} as any);
|
||||
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
type: 'chat-inventory-request',
|
||||
roomId: room.id
|
||||
@@ -119,6 +130,7 @@ export class MessagesSyncEffects {
|
||||
count,
|
||||
lastUpdated
|
||||
} as any);
|
||||
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-inventory-request',
|
||||
roomId: activeRoom.id
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* Action type strings follow the `[Messages] Event Name` convention and are
|
||||
* 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';
|
||||
|
||||
export const MessagesActions = createActionGroup({
|
||||
|
||||
@@ -10,10 +10,23 @@
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
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 { of, from, EMPTY } from 'rxjs';
|
||||
import { mergeMap, catchError, withLatestFrom, switchMap } from 'rxjs/operators';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
EMPTY
|
||||
} from 'rxjs';
|
||||
import {
|
||||
mergeMap,
|
||||
catchError,
|
||||
withLatestFrom,
|
||||
switchMap
|
||||
} from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MessagesActions } from './messages.actions';
|
||||
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 { Message, Reaction } from '../../core/models';
|
||||
import { hydrateMessages } from './messages.helpers';
|
||||
import {
|
||||
dispatchIncomingMessage,
|
||||
IncomingMessageContext
|
||||
} from './messages-incoming.handlers';
|
||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||
|
||||
@Injectable()
|
||||
export class MessagesEffects {
|
||||
@@ -65,7 +75,11 @@ export class MessagesEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
|
||||
mergeMap(([
|
||||
{ content, replyToId, channelId },
|
||||
currentUser,
|
||||
currentRoom
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
|
||||
}
|
||||
@@ -84,7 +98,8 @@ export class MessagesEffects {
|
||||
};
|
||||
|
||||
this.db.saveMessage(message);
|
||||
this.webrtc.broadcastMessage({ type: 'chat-message', message });
|
||||
this.webrtc.broadcastMessage({ type: 'chat-message',
|
||||
message });
|
||||
|
||||
return of(MessagesActions.sendMessageSuccess({ message }));
|
||||
}),
|
||||
@@ -116,10 +131,17 @@ export class MessagesEffects {
|
||||
|
||||
const editedAt = this.timeSync.now();
|
||||
|
||||
this.db.updateMessage(messageId, { content, editedAt });
|
||||
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
|
||||
this.db.updateMessage(messageId, { content,
|
||||
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) =>
|
||||
of(MessagesActions.editMessageFailure({ error: error.message }))
|
||||
@@ -150,7 +172,8 @@ export class MessagesEffects {
|
||||
}
|
||||
|
||||
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 }));
|
||||
}),
|
||||
@@ -182,7 +205,9 @@ export class MessagesEffects {
|
||||
}
|
||||
|
||||
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 }));
|
||||
}),
|
||||
@@ -211,7 +236,9 @@ export class MessagesEffects {
|
||||
};
|
||||
|
||||
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 }));
|
||||
})
|
||||
@@ -256,7 +283,11 @@ export class MessagesEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
|
||||
mergeMap(([
|
||||
event,
|
||||
currentUser,
|
||||
currentRoom]: [any, any, any
|
||||
]) => {
|
||||
const ctx: IncomingMessageContext = {
|
||||
db: this.db,
|
||||
webrtc: this.webrtc,
|
||||
|
||||
@@ -56,7 +56,8 @@ export async function hydrateMessage(
|
||||
): Promise<Message> {
|
||||
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. */
|
||||
@@ -81,7 +82,9 @@ export async function buildInventoryItem(
|
||||
): Promise<InventoryItem> {
|
||||
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. */
|
||||
@@ -95,9 +98,11 @@ export async function buildLocalInventoryMap(
|
||||
messages.map(async (msg) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -161,18 +166,22 @@ export async function mergeIncomingMessage(
|
||||
const baseMessage = isNewer ? incoming : existing;
|
||||
|
||||
if (!baseMessage) {
|
||||
return { message: incoming, changed };
|
||||
return { message: incoming,
|
||||
changed };
|
||||
}
|
||||
|
||||
return {
|
||||
message: { ...baseMessage, reactions },
|
||||
message: { ...baseMessage,
|
||||
reactions },
|
||||
changed
|
||||
};
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
return { message: incoming, changed: false };
|
||||
return { message: incoming,
|
||||
changed: false };
|
||||
}
|
||||
|
||||
return { message: existing, changed: false };
|
||||
return { message: existing,
|
||||
changed: false };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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 { MessagesActions } from './messages.actions';
|
||||
|
||||
@@ -30,7 +34,7 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
|
||||
export const messagesReducer = createReducer(
|
||||
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 }) => {
|
||||
if (state.currentRoomId && state.currentRoomId !== roomId) {
|
||||
return messagesAdapter.removeAll({
|
||||
@@ -91,7 +95,8 @@ export const messagesReducer = createReducer(
|
||||
messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: { content, editedAt }
|
||||
changes: { content,
|
||||
editedAt }
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -102,7 +107,8 @@ export const messagesReducer = createReducer(
|
||||
messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: { isDeleted: true, content: '[Message deleted]' }
|
||||
changes: { isDeleted: true,
|
||||
content: '[Message deleted]' }
|
||||
},
|
||||
state
|
||||
)
|
||||
@@ -184,7 +190,8 @@ export const messagesReducer = createReducer(
|
||||
}
|
||||
}
|
||||
|
||||
return { ...message, reactions: combined };
|
||||
return { ...message,
|
||||
reactions: combined };
|
||||
}
|
||||
|
||||
return message;
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
/**
|
||||
* Rooms store actions using `createActionGroup`.
|
||||
*/
|
||||
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
||||
import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models';
|
||||
import {
|
||||
createActionGroup,
|
||||
emptyProps,
|
||||
props
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
Room,
|
||||
RoomSettings,
|
||||
ServerInfo,
|
||||
RoomPermissions,
|
||||
Channel
|
||||
} from '../../core/models';
|
||||
|
||||
export const RoomsActions = createActionGroup({
|
||||
source: 'Rooms',
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of, from, EMPTY } from 'rxjs';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
EMPTY
|
||||
} from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
mergeMap,
|
||||
@@ -25,7 +33,12 @@ import { selectCurrentRoom } from './rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.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';
|
||||
|
||||
/** Build a minimal User object from signaling payload. */
|
||||
@@ -285,11 +298,16 @@ export class RoomsEffects {
|
||||
ofType(RoomsActions.deleteRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
filter(
|
||||
([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id
|
||||
([
|
||||
, currentUser,
|
||||
currentRoom
|
||||
]) => !!currentUser && currentRoom?.hostId === currentUser.id
|
||||
),
|
||||
switchMap(([{ roomId }]) => {
|
||||
this.db.deleteRoom(roomId);
|
||||
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId });
|
||||
this.webrtc.broadcastMessage({ type: 'room-deleted',
|
||||
roomId });
|
||||
|
||||
this.webrtc.disconnectAll();
|
||||
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
||||
})
|
||||
@@ -318,7 +336,11 @@ export class RoomsEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateRoomSettings),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ settings }, currentUser, currentRoom]) => {
|
||||
mergeMap(([
|
||||
{ settings },
|
||||
currentUser,
|
||||
currentRoom
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' }));
|
||||
}
|
||||
@@ -377,15 +399,22 @@ export class RoomsEffects {
|
||||
ofType(RoomsActions.updateRoomPermissions),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
filter(
|
||||
([{ roomId }, currentUser, currentRoom]) =>
|
||||
([
|
||||
{ roomId },
|
||||
currentUser,
|
||||
currentRoom
|
||||
]) =>
|
||||
!!currentUser &&
|
||||
!!currentRoom &&
|
||||
currentRoom.id === roomId &&
|
||||
currentRoom.hostId === currentUser.id
|
||||
),
|
||||
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
|
||||
mergeMap(([
|
||||
{ roomId, permissions }, , currentRoom
|
||||
]) => {
|
||||
const updated: Partial<Room> = {
|
||||
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions
|
||||
permissions: { ...(currentRoom!.permissions || {}),
|
||||
...permissions } as RoomPermissions
|
||||
};
|
||||
|
||||
this.db.updateRoom(roomId, updated);
|
||||
@@ -394,7 +423,9 @@ export class RoomsEffects {
|
||||
type: 'room-permissions-update',
|
||||
permissions: updated.permissions
|
||||
} 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(
|
||||
ofType(RoomsActions.updateServerIcon),
|
||||
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) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
|
||||
}
|
||||
@@ -421,7 +456,8 @@ export class RoomsEffects {
|
||||
}
|
||||
|
||||
const iconUpdatedAt = Date.now();
|
||||
const changes: Partial<Room> = { icon, iconUpdatedAt };
|
||||
const changes: Partial<Room> = { icon,
|
||||
iconUpdatedAt };
|
||||
|
||||
this.db.updateRoom(roomId, changes);
|
||||
// Broadcast to peers
|
||||
@@ -431,7 +467,10 @@ export class RoomsEffects {
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
} 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(() =>
|
||||
this.webrtc.onSignalingMessage.pipe(
|
||||
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 viewedServerId = currentRoom?.id;
|
||||
|
||||
@@ -533,7 +576,11 @@ export class RoomsEffects {
|
||||
this.webrtc.onMessageReceived.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectAllUsers)),
|
||||
filter(([, room]) => !!room),
|
||||
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
|
||||
mergeMap(([
|
||||
event,
|
||||
currentRoom,
|
||||
allUsers]: [any, any, any[]
|
||||
]) => {
|
||||
const room = currentRoom as Room;
|
||||
|
||||
switch (event.type) {
|
||||
@@ -590,7 +637,8 @@ export class RoomsEffects {
|
||||
return of(
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || 'User' },
|
||||
{
|
||||
voiceState: {
|
||||
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
|
||||
@@ -621,7 +670,8 @@ export class RoomsEffects {
|
||||
return of(
|
||||
UsersActions.userJoined({
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||
{ oderId: userId,
|
||||
displayName: event.displayName || 'User' },
|
||||
{ screenShareState: { isSharing } }
|
||||
)
|
||||
})
|
||||
@@ -700,7 +750,8 @@ export class RoomsEffects {
|
||||
};
|
||||
|
||||
this.db.updateRoom(room.id, updates);
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id,
|
||||
changes: updates }));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
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';
|
||||
|
||||
/** Default channels for a new server */
|
||||
export function defaultChannels(): Channel[] {
|
||||
return [
|
||||
{ id: 'general', name: 'general', type: 'text', position: 0 },
|
||||
{ id: 'random', name: 'random', type: 'text', position: 1 },
|
||||
{ id: 'vc-general', name: 'General', type: 'voice', position: 0 },
|
||||
{ id: 'vc-afk', name: 'AFK', type: 'voice', position: 1 }
|
||||
{ id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
position: 0 },
|
||||
{ id: 'random',
|
||||
name: 'random',
|
||||
type: 'text',
|
||||
position: 1 },
|
||||
{ id: 'vc-general',
|
||||
name: 'General',
|
||||
type: 'voice',
|
||||
position: 0 },
|
||||
{ id: 'vc-afk',
|
||||
name: 'AFK',
|
||||
type: 'voice',
|
||||
position: 1 }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -123,7 +140,8 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
on(RoomsActions.createRoomSuccess, (state, { room }) => {
|
||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||
const enriched = { ...room,
|
||||
channels: room.channels || defaultChannels() };
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -149,7 +167,8 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
|
||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||
const enriched = { ...room,
|
||||
channels: room.channels || defaultChannels() };
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -181,7 +200,7 @@ export const roomsReducer = createReducer(
|
||||
isConnected: false
|
||||
})),
|
||||
|
||||
// View server – just switch the viewed room, stay connected
|
||||
// View server - just switch the viewed room, stay connected
|
||||
on(RoomsActions.viewServer, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
@@ -189,7 +208,8 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||
const enriched = { ...room,
|
||||
channels: room.channels || defaultChannels() };
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -264,7 +284,8 @@ export const roomsReducer = createReducer(
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: { ...state.currentRoom, ...changes }
|
||||
currentRoom: { ...state.currentRoom,
|
||||
...changes }
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -275,14 +296,17 @@ export const roomsReducer = createReducer(
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: { ...state.currentRoom, icon, iconUpdatedAt }
|
||||
currentRoom: { ...state.currentRoom,
|
||||
icon,
|
||||
iconUpdatedAt }
|
||||
};
|
||||
}),
|
||||
|
||||
// Receive room update
|
||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null
|
||||
currentRoom: state.currentRoom ? { ...state.currentRoom,
|
||||
...room } : null
|
||||
})),
|
||||
|
||||
// Clear search results
|
||||
@@ -309,7 +333,8 @@ export const roomsReducer = createReducer(
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = [...existing, channel];
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -324,7 +349,8 @@ export const roomsReducer = createReducer(
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -339,8 +365,10 @@ export const roomsReducer = createReducer(
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel);
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
|
||||
name } : channel);
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* Users store actions using `createActionGroup`.
|
||||
*/
|
||||
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
||||
import { User, BanEntry, VoiceState, ScreenShareState } from '../../core/models';
|
||||
import {
|
||||
createActionGroup,
|
||||
emptyProps,
|
||||
props
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
User,
|
||||
BanEntry,
|
||||
VoiceState,
|
||||
ScreenShareState
|
||||
} from '../../core/models';
|
||||
|
||||
export const UsersActions = createActionGroup({
|
||||
source: 'Users',
|
||||
|
||||
@@ -3,13 +3,32 @@
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
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 { of, from, EMPTY } from 'rxjs';
|
||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
EMPTY
|
||||
} from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
mergeMap,
|
||||
catchError,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
switchMap
|
||||
} from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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 { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
@@ -67,7 +86,11 @@ export class UsersEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ userId }, currentUser, currentRoom]) => {
|
||||
mergeMap(([
|
||||
{ userId },
|
||||
currentUser,
|
||||
currentRoom
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom)
|
||||
return EMPTY;
|
||||
|
||||
@@ -99,7 +122,11 @@ export class UsersEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
|
||||
mergeMap(([
|
||||
{ userId, reason, expiresAt },
|
||||
currentUser,
|
||||
currentRoom
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom)
|
||||
return EMPTY;
|
||||
|
||||
@@ -127,7 +154,8 @@ export class UsersEffects {
|
||||
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(selectCurrentUserId)
|
||||
),
|
||||
mergeMap(([{ userId }, hostId, currentUserId]) =>
|
||||
mergeMap(([
|
||||
{ userId },
|
||||
hostId,
|
||||
currentUserId
|
||||
]) =>
|
||||
userId === hostId && currentUserId
|
||||
? of(UsersActions.updateHost({ userId: currentUserId }))
|
||||
: EMPTY
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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 { UsersActions } from './users.actions';
|
||||
|
||||
|
||||
24
tools/eslint-rules.js
Normal file
24
tools/eslint-rules.js
Normal 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
138
tools/format-templates.js
Normal 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);
|
||||
88
tools/prettier-post-processor.js
Normal file
88
tools/prettier-post-processor.js
Normal 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 };
|
||||
121
tools/sort-template-properties.js
Normal file
121
tools/sort-template-properties.js
Normal 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);
|
||||
Reference in New Issue
Block a user